@elytracms/studio 0.0.85 → 0.0.87
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/convex/asset-revalidate.test.ts +186 -0
- package/convex/assets.ts +53 -19
- package/convex/cms.ts +22 -0
- package/convex/delivery.ts +54 -116
- package/convex/engine-version.test.ts +31 -0
- package/convex/functions.ts +3 -3
- package/convex/rail-write-parity.test.ts +180 -0
- package/convex/redirects.ts +33 -11
- package/convex/revalidate.ts +7 -5
- package/convex/triggers.ts +53 -3
- package/dist/components/ai/ai-chat-panel.js +13 -2
- package/dist/components/ai/ai-chat-panel.js.map +1 -1
- package/dist/components/app-views/default-modules.d.ts +20 -20
- package/dist/components/app-views/default-modules.js +27 -48
- package/dist/components/app-views/default-modules.js.map +1 -1
- package/dist/components/app-views/studio-config.d.ts +5 -6
- package/dist/components/app-views/studio-config.js.map +1 -1
- package/dist/components/assets/asset-metadata.d.ts +28 -19
- package/dist/components/assets/asset-metadata.js +24 -31
- package/dist/components/assets/asset-metadata.js.map +1 -1
- package/dist/components/assets/asset-selection.d.ts +24 -5
- package/dist/components/assets/asset-selection.js +39 -9
- package/dist/components/assets/asset-selection.js.map +1 -1
- package/dist/components/assets/assets-collection-view-source.d.ts +17 -14
- package/dist/components/assets/assets-collection-view-source.js +25 -31
- package/dist/components/assets/assets-collection-view-source.js.map +1 -1
- package/dist/components/builder/blocks-model.d.ts +5 -4
- package/dist/components/builder/blocks-model.js +5 -4
- package/dist/components/builder/blocks-model.js.map +1 -1
- package/dist/components/builder/blocks-panel.js +3 -3
- package/dist/components/builder/blocks-panel.js.map +1 -1
- package/dist/components/builder/builder-store.d.ts +25 -1
- package/dist/components/builder/builder-store.js +3 -2
- package/dist/components/builder/builder-store.js.map +1 -1
- package/dist/components/builder/canvas-chrome-css.js +0 -17
- package/dist/components/builder/canvas-chrome-css.js.map +1 -1
- package/dist/components/builder/canvas-chrome.d.ts +10 -12
- package/dist/components/builder/canvas-chrome.js +12 -13
- package/dist/components/builder/canvas-chrome.js.map +1 -1
- package/dist/components/builder/canvas-dnd.d.ts +8 -0
- package/dist/components/builder/canvas-dnd.js +5 -3
- package/dist/components/builder/canvas-dnd.js.map +1 -1
- package/dist/components/builder/canvas-render-context.d.ts +12 -24
- package/dist/components/builder/canvas-render-context.js +15 -44
- package/dist/components/builder/canvas-render-context.js.map +1 -1
- package/dist/components/builder/canvas.js +5 -11
- package/dist/components/builder/canvas.js.map +1 -1
- package/dist/components/builder/clipboard-actions.d.ts +15 -1
- package/dist/components/builder/clipboard-actions.js +35 -12
- package/dist/components/builder/clipboard-actions.js.map +1 -1
- package/dist/components/builder/composition-field-editor.d.ts +32 -26
- package/dist/components/builder/composition-field-editor.js +47 -54
- package/dist/components/builder/composition-field-editor.js.map +1 -1
- package/dist/components/builder/insert-context.d.ts +3 -1
- package/dist/components/builder/insert-context.js +19 -12
- package/dist/components/builder/insert-context.js.map +1 -1
- package/dist/components/builder/page-tree.d.ts +29 -0
- package/dist/components/builder/page-tree.js +56 -58
- package/dist/components/builder/page-tree.js.map +1 -1
- package/dist/components/builder/reuse.js +5 -2
- package/dist/components/builder/reuse.js.map +1 -1
- package/dist/components/cms/asset-field.js +20 -32
- package/dist/components/cms/asset-field.js.map +1 -1
- package/dist/components/cms/cell-edit.d.ts +7 -0
- package/dist/components/cms/cell-edit.js +9 -1
- package/dist/components/cms/cell-edit.js.map +1 -1
- package/dist/components/cms/cms-composition-validators.js +11 -2
- package/dist/components/cms/cms-composition-validators.js.map +1 -1
- package/dist/components/cms/collection-table.js +11 -0
- package/dist/components/cms/collection-table.js.map +1 -1
- package/dist/components/cms/document-url-path.d.ts +7 -4
- package/dist/components/cms/document-url-path.js +15 -1
- package/dist/components/cms/document-url-path.js.map +1 -1
- package/dist/components/cms/editable-cell.js +5 -2
- package/dist/components/cms/editable-cell.js.map +1 -1
- package/dist/components/cms/field-control.js +4 -0
- package/dist/components/cms/field-control.js.map +1 -1
- package/dist/components/cms/inline-field-errors.d.ts +36 -0
- package/dist/components/cms/inline-field-errors.js +13 -5
- package/dist/components/cms/inline-field-errors.js.map +1 -1
- package/dist/components/cms/rich-text/insert.d.ts +6 -3
- package/dist/components/cms/rich-text/insert.js +9 -6
- package/dist/components/cms/rich-text/insert.js.map +1 -1
- package/dist/components/cms/use-document-versions.js +1 -0
- package/dist/components/cms/use-document-versions.js.map +1 -1
- package/dist/components/cms/use-live-url.d.ts +18 -6
- package/dist/components/cms/use-live-url.js +10 -0
- package/dist/components/cms/use-live-url.js.map +1 -1
- package/dist/components/cms/versions-model.d.ts +0 -12
- package/dist/components/cms/versions-model.js +3 -15
- package/dist/components/cms/versions-model.js.map +1 -1
- package/dist/components/components/component-detail.js +1 -1
- package/dist/components/components/component-detail.js.map +1 -1
- package/dist/components/components/transfer-dialog.js +2 -4
- package/dist/components/components/transfer-dialog.js.map +1 -1
- package/dist/components/components/transfer.d.ts +19 -15
- package/dist/components/components/transfer.js +28 -25
- package/dist/components/components/transfer.js.map +1 -1
- package/dist/components/deployment-setup-gate.js +9 -3
- package/dist/components/deployment-setup-gate.js.map +1 -1
- package/dist/components/project-session.d.ts +9 -0
- package/dist/components/project-session.js +1 -1
- package/dist/components/project-session.js.map +1 -1
- package/dist/components/redirects/redirects-collection-view-source.d.ts +17 -2
- package/dist/components/redirects/redirects-collection-view-source.js +20 -6
- package/dist/components/redirects/redirects-collection-view-source.js.map +1 -1
- package/dist/components/section-surface.js +3 -2
- package/dist/components/section-surface.js.map +1 -1
- package/dist/components/settings-form.js +1 -2
- package/dist/components/settings-form.js.map +1 -1
- package/dist/components/studio-context.js +1 -18
- package/dist/components/studio-context.js.map +1 -1
- package/dist/components/studio-project-screen.js +3 -5
- package/dist/components/studio-project-screen.js.map +1 -1
- package/dist/lib/ai/apply-content.js +73 -29
- package/dist/lib/ai/apply-content.js.map +1 -1
- package/dist/lib/ai/constraints.d.ts +35 -0
- package/dist/lib/ai/constraints.js +155 -72
- package/dist/lib/ai/constraints.js.map +1 -1
- package/dist/lib/ai/index.d.ts +1 -0
- package/dist/lib/ai/index.js +1 -0
- package/dist/lib/ai/index.js.map +1 -1
- package/dist/lib/ai/route-fetch.d.ts +58 -0
- package/dist/lib/ai/route-fetch.js +98 -0
- package/dist/lib/ai/route-fetch.js.map +1 -0
- package/dist/lib/ai/route-provider.d.ts +5 -22
- package/dist/lib/ai/route-provider.js +23 -1
- package/dist/lib/ai/route-provider.js.map +1 -1
- package/dist/lib/ai/route.js +4 -1
- package/dist/lib/ai/route.js.map +1 -1
- package/dist/lib/ai/shape.d.ts +3 -2
- package/dist/lib/ai/shape.js +16 -24
- package/dist/lib/ai/shape.js.map +1 -1
- package/dist/lib/ai/types.d.ts +8 -0
- package/dist/lib/ai/types.js.map +1 -1
- package/dist/lib/ai/update-fields.js +33 -12
- package/dist/lib/ai/update-fields.js.map +1 -1
- package/dist/lib/assets/asset-placements.d.ts +35 -0
- package/dist/lib/assets/asset-placements.js +74 -0
- package/dist/lib/assets/asset-placements.js.map +1 -0
- package/dist/lib/assets/backfill-thumbs.d.ts +9 -8
- package/dist/lib/assets/backfill-thumbs.js +13 -25
- package/dist/lib/assets/backfill-thumbs.js.map +1 -1
- package/dist/lib/clipboard/paste-issues.d.ts +17 -15
- package/dist/lib/clipboard/paste-issues.js +8 -45
- package/dist/lib/clipboard/paste-issues.js.map +1 -1
- package/dist/lib/clipboard/paste-target.d.ts +47 -1
- package/dist/lib/clipboard/paste-target.js +19 -11
- package/dist/lib/clipboard/paste-target.js.map +1 -1
- package/dist/lib/cms/document-gate.d.ts +19 -9
- package/dist/lib/cms/document-gate.js +25 -4
- package/dist/lib/cms/document-gate.js.map +1 -1
- package/dist/lib/cms/published-documents.js +1 -0
- package/dist/lib/cms/published-documents.js.map +1 -1
- package/dist/lib/cms-views/view-model.d.ts +3 -3
- package/dist/lib/data/index.d.ts +0 -3
- package/dist/lib/data/index.js +4 -6
- package/dist/lib/data/index.js.map +1 -1
- package/dist/lib/dnd/drop-rules.d.ts +45 -2
- package/dist/lib/dnd/drop-rules.js +64 -9
- package/dist/lib/dnd/drop-rules.js.map +1 -1
- package/dist/lib/engine-version.d.ts +16 -0
- package/dist/lib/engine-version.js +17 -0
- package/dist/lib/engine-version.js.map +1 -0
- package/dist/lib/i18n/messages.de.js +8 -56
- package/dist/lib/i18n/messages.de.js.map +1 -1
- package/dist/lib/i18n/messages.en.d.ts +8 -53
- package/dist/lib/i18n/messages.en.js +15 -57
- package/dist/lib/i18n/messages.en.js.map +1 -1
- package/dist/lib/locale/fallback.js +8 -2
- package/dist/lib/locale/fallback.js.map +1 -1
- package/dist/lib/locale/index.d.ts +0 -1
- package/dist/lib/locale/index.js +0 -1
- package/dist/lib/locale/index.js.map +1 -1
- package/dist/lib/locale/locale-store.d.ts +10 -3
- package/dist/lib/locale/locale-store.js +12 -5
- package/dist/lib/locale/locale-store.js.map +1 -1
- package/dist/lib/operations/session-hydration.d.ts +9 -5
- package/dist/lib/operations/session-hydration.js +10 -10
- package/dist/lib/operations/session-hydration.js.map +1 -1
- package/dist/lib/operations/studio-operations.js +4 -2
- package/dist/lib/operations/studio-operations.js.map +1 -1
- package/dist/lib/persistence/convex-adapter.js +63 -75
- package/dist/lib/persistence/convex-adapter.js.map +1 -1
- package/dist/lib/persistence/deployment-readiness.d.ts +8 -1
- package/dist/lib/persistence/deployment-readiness.js +4 -0
- package/dist/lib/persistence/deployment-readiness.js.map +1 -1
- package/dist/lib/persona/labels.d.ts +0 -2
- package/dist/lib/persona/labels.js +1 -7
- package/dist/lib/persona/labels.js.map +1 -1
- package/dist/lib/registry/index.d.ts +4 -2
- package/dist/lib/registry/index.js +26 -21
- package/dist/lib/registry/index.js.map +1 -1
- package/dist/lib/registry/project-components.js +0 -3
- package/dist/lib/registry/project-components.js.map +1 -1
- package/dist/lib/routes/route-issues.js +4 -5
- package/dist/lib/routes/route-issues.js.map +1 -1
- package/dist/lib/theme.js +3 -2
- package/dist/lib/theme.js.map +1 -1
- package/dist/lib/webhooks/tags.js +11 -14
- package/dist/lib/webhooks/tags.js.map +1 -1
- package/dist/lib/workspace/index.d.ts +1 -1
- package/dist/lib/workspace/index.js +1 -1
- package/dist/lib/workspace/index.js.map +1 -1
- package/dist/previews/enumerate.d.ts +27 -9
- package/dist/previews/enumerate.js +27 -6
- package/dist/previews/enumerate.js.map +1 -1
- package/dist/previews/manifest-format.d.ts +16 -7
- package/dist/previews/manifest-format.js +0 -21
- package/dist/previews/manifest-format.js.map +1 -1
- package/dist/router/match.d.ts +0 -2
- package/dist/router/match.js +0 -2
- package/dist/router/match.js.map +1 -1
- package/dist/ui/badge.d.ts +1 -1
- package/package.json +2 -2
- package/src/styles/studio.css +5 -4
- package/src/styles/tokens.css +72 -55
- package/convex/content.ts +0 -291
- package/dist/components/collab/collab-context.d.ts +0 -27
- package/dist/components/collab/collab-context.js +0 -39
- package/dist/components/collab/collab-context.js.map +0 -1
- package/dist/components/collab/collab-seed.d.ts +0 -15
- package/dist/components/collab/collab-seed.js +0 -20
- package/dist/components/collab/collab-seed.js.map +0 -1
- package/dist/components/extensions/extensions-view.d.ts +0 -7
- package/dist/components/extensions/extensions-view.js +0 -127
- package/dist/components/extensions/extensions-view.js.map +0 -1
- package/dist/lib/bindings/section-bindings.d.ts +0 -24
- package/dist/lib/bindings/section-bindings.js +0 -9
- package/dist/lib/bindings/section-bindings.js.map +0 -1
- package/dist/lib/data/data-store.d.ts +0 -98
- package/dist/lib/data/data-store.js +0 -211
- package/dist/lib/data/data-store.js.map +0 -1
- package/dist/lib/data/providers.d.ts +0 -143
- package/dist/lib/data/providers.js +0 -255
- package/dist/lib/data/providers.js.map +0 -1
- package/dist/lib/data/sample-capture.d.ts +0 -30
- package/dist/lib/data/sample-capture.js +0 -70
- package/dist/lib/data/sample-capture.js.map +0 -1
- package/dist/lib/data/sources-store.d.ts +0 -42
- package/dist/lib/data/sources-store.js +0 -120
- package/dist/lib/data/sources-store.js.map +0 -1
- package/dist/lib/locale/binding-fallback.d.ts +0 -35
- package/dist/lib/locale/binding-fallback.js +0 -73
- package/dist/lib/locale/binding-fallback.js.map +0 -1
- package/dist/lib/starters/kit-session.d.ts +0 -5
- package/dist/lib/starters/kit-session.js +0 -17
- package/dist/lib/starters/kit-session.js.map +0 -1
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The AI route's response-shape guard (EC-514 rider): ONE wrapping `fetch` shared by
|
|
3
|
+
* both client call sites of the mounted route — the panel's chat transport
|
|
4
|
+
* (`useChat` + `DefaultChatTransport`, which accepts a `fetch` option) and the
|
|
5
|
+
* structured proxy in `createRouteProvider` — so "the route is not mounted at this
|
|
6
|
+
* path" is a typed, nameable failure at both, instead of whatever each transport
|
|
7
|
+
* makes of a page of HTML.
|
|
8
|
+
*
|
|
9
|
+
* Why this exists (measured, EC-514): with the EC-503 dev degrade active (studio
|
|
10
|
+
* `dist` missing → the Vite dev mount is skipped) a `POST /api/ai` falls through to
|
|
11
|
+
* the SPA shell — `200 text/html`. The chat transport treats a 200 as success and
|
|
12
|
+
* parses the HTML as an event stream; the HTML carries no `data:` lines, so the
|
|
13
|
+
* stream ends EMPTY and the panel shows the user's turn followed by nothing — no
|
|
14
|
+
* error note, no assistant turn. A silent failure, the exact state AGENTS.md §4
|
|
15
|
+
* forbids. The structured proxy's path is no better by construction: a 200 is
|
|
16
|
+
* `response.ok`, so it calls `response.json()` on `<!DOCTYPE …` — a parse error
|
|
17
|
+
* that names neither the path nor the cause.
|
|
18
|
+
*
|
|
19
|
+
* The contract this guard enforces is the route's OWN: `createAiRouteHandler` only
|
|
20
|
+
* ever answers `application/json` (every non-stream branch, `route.ts`
|
|
21
|
+
* `jsonResponse`) or the SDK's UI message stream (`text/event-stream`,
|
|
22
|
+
* `createUIMessageStreamResponse`). Anything else — an HTML shell, a host's 404
|
|
23
|
+
* page, a proxy's error page — was not produced by the route, so the request did
|
|
24
|
+
* not reach a mounted route at that path. Positive detection (what the route DOES
|
|
25
|
+
* answer) rather than a blocklist of page types, so a new kind of "something else"
|
|
26
|
+
* cannot slip past as success.
|
|
27
|
+
*
|
|
28
|
+
* Bundle discipline: imports nothing from `ai` / `@ai-sdk/*` (pinned in
|
|
29
|
+
* `bundle.test.ts`) — it is a plain `fetch` decorator.
|
|
30
|
+
*/
|
|
31
|
+
/** The bare media type of a `content-type` header (`text/html; charset=utf-8` → `text/html`). */
|
|
32
|
+
export declare function mediaTypeOf(contentType: string | null | undefined): string | null;
|
|
33
|
+
/** True when a response's content type is one the AI route itself produces. */
|
|
34
|
+
export declare function isAiRouteAnswer(contentType: string | null | undefined): boolean;
|
|
35
|
+
/**
|
|
36
|
+
* Thrown by {@link guardAiRouteFetch} when the answer at `endpoint` was not produced
|
|
37
|
+
* by the AI route — no route is mounted there (or something in front of it
|
|
38
|
+
* answered). Carries what a developer needs to act: the path, the status, and the
|
|
39
|
+
* content type that came back. The `message` is self-describing on purpose: the
|
|
40
|
+
* tool executor (`tools.ts`) surfaces `error.message` verbatim as a tool step's
|
|
41
|
+
* error text, and a bare "unavailable" there would hide the cause again.
|
|
42
|
+
*/
|
|
43
|
+
export declare class AiRouteNotMountedError extends Error {
|
|
44
|
+
readonly endpoint: string;
|
|
45
|
+
readonly status: number;
|
|
46
|
+
readonly contentType: string | null;
|
|
47
|
+
readonly name = "AiRouteNotMountedError";
|
|
48
|
+
constructor(endpoint: string, status: number, contentType: string | null);
|
|
49
|
+
}
|
|
50
|
+
/** Type guard for {@link AiRouteNotMountedError} (works across bundle copies — checks the name). */
|
|
51
|
+
export declare function isAiRouteNotMountedError(error: unknown): error is AiRouteNotMountedError;
|
|
52
|
+
/**
|
|
53
|
+
* Wrap a `fetch` so a response the AI route could not have produced throws
|
|
54
|
+
* {@link AiRouteNotMountedError} instead of being handed to a JSON/stream parser.
|
|
55
|
+
* Everything else — including the route's own error answers (401/429/502 JSON) —
|
|
56
|
+
* passes through unchanged for the existing handlers.
|
|
57
|
+
*/
|
|
58
|
+
export declare function guardAiRouteFetch(fetchImpl?: typeof fetch): typeof fetch;
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The AI route's response-shape guard (EC-514 rider): ONE wrapping `fetch` shared by
|
|
3
|
+
* both client call sites of the mounted route — the panel's chat transport
|
|
4
|
+
* (`useChat` + `DefaultChatTransport`, which accepts a `fetch` option) and the
|
|
5
|
+
* structured proxy in `createRouteProvider` — so "the route is not mounted at this
|
|
6
|
+
* path" is a typed, nameable failure at both, instead of whatever each transport
|
|
7
|
+
* makes of a page of HTML.
|
|
8
|
+
*
|
|
9
|
+
* Why this exists (measured, EC-514): with the EC-503 dev degrade active (studio
|
|
10
|
+
* `dist` missing → the Vite dev mount is skipped) a `POST /api/ai` falls through to
|
|
11
|
+
* the SPA shell — `200 text/html`. The chat transport treats a 200 as success and
|
|
12
|
+
* parses the HTML as an event stream; the HTML carries no `data:` lines, so the
|
|
13
|
+
* stream ends EMPTY and the panel shows the user's turn followed by nothing — no
|
|
14
|
+
* error note, no assistant turn. A silent failure, the exact state AGENTS.md §4
|
|
15
|
+
* forbids. The structured proxy's path is no better by construction: a 200 is
|
|
16
|
+
* `response.ok`, so it calls `response.json()` on `<!DOCTYPE …` — a parse error
|
|
17
|
+
* that names neither the path nor the cause.
|
|
18
|
+
*
|
|
19
|
+
* The contract this guard enforces is the route's OWN: `createAiRouteHandler` only
|
|
20
|
+
* ever answers `application/json` (every non-stream branch, `route.ts`
|
|
21
|
+
* `jsonResponse`) or the SDK's UI message stream (`text/event-stream`,
|
|
22
|
+
* `createUIMessageStreamResponse`). Anything else — an HTML shell, a host's 404
|
|
23
|
+
* page, a proxy's error page — was not produced by the route, so the request did
|
|
24
|
+
* not reach a mounted route at that path. Positive detection (what the route DOES
|
|
25
|
+
* answer) rather than a blocklist of page types, so a new kind of "something else"
|
|
26
|
+
* cannot slip past as success.
|
|
27
|
+
*
|
|
28
|
+
* Bundle discipline: imports nothing from `ai` / `@ai-sdk/*` (pinned in
|
|
29
|
+
* `bundle.test.ts`) — it is a plain `fetch` decorator.
|
|
30
|
+
*/
|
|
31
|
+
/** Media types the mounted route answers with — JSON, or the SDK's UI message stream. */
|
|
32
|
+
const ROUTE_MEDIA_TYPES = new Set(['application/json', 'text/event-stream']);
|
|
33
|
+
/** The bare media type of a `content-type` header (`text/html; charset=utf-8` → `text/html`). */
|
|
34
|
+
export function mediaTypeOf(contentType) {
|
|
35
|
+
if (!contentType)
|
|
36
|
+
return null;
|
|
37
|
+
const bare = contentType.split(';')[0]?.trim().toLowerCase() ?? '';
|
|
38
|
+
return bare.length > 0 ? bare : null;
|
|
39
|
+
}
|
|
40
|
+
/** True when a response's content type is one the AI route itself produces. */
|
|
41
|
+
export function isAiRouteAnswer(contentType) {
|
|
42
|
+
const media = mediaTypeOf(contentType);
|
|
43
|
+
return media !== null && ROUTE_MEDIA_TYPES.has(media);
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* Thrown by {@link guardAiRouteFetch} when the answer at `endpoint` was not produced
|
|
47
|
+
* by the AI route — no route is mounted there (or something in front of it
|
|
48
|
+
* answered). Carries what a developer needs to act: the path, the status, and the
|
|
49
|
+
* content type that came back. The `message` is self-describing on purpose: the
|
|
50
|
+
* tool executor (`tools.ts`) surfaces `error.message` verbatim as a tool step's
|
|
51
|
+
* error text, and a bare "unavailable" there would hide the cause again.
|
|
52
|
+
*/
|
|
53
|
+
export class AiRouteNotMountedError extends Error {
|
|
54
|
+
endpoint;
|
|
55
|
+
status;
|
|
56
|
+
contentType;
|
|
57
|
+
name = 'AiRouteNotMountedError';
|
|
58
|
+
constructor(endpoint, status, contentType) {
|
|
59
|
+
super(`No AI route is mounted at ${endpoint}: the request was answered with ` +
|
|
60
|
+
`${contentType ?? 'no content type'} (HTTP ${status}) instead of the route's JSON or event stream.`);
|
|
61
|
+
this.endpoint = endpoint;
|
|
62
|
+
this.status = status;
|
|
63
|
+
this.contentType = contentType;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
/** Type guard for {@link AiRouteNotMountedError} (works across bundle copies — checks the name). */
|
|
67
|
+
export function isAiRouteNotMountedError(error) {
|
|
68
|
+
return (error instanceof AiRouteNotMountedError ||
|
|
69
|
+
(typeof error === 'object' &&
|
|
70
|
+
error !== null &&
|
|
71
|
+
error.name === 'AiRouteNotMountedError' &&
|
|
72
|
+
typeof error.endpoint === 'string'));
|
|
73
|
+
}
|
|
74
|
+
/** The path a `fetch` input names (string, `URL`, or `Request`). */
|
|
75
|
+
function endpointOf(input) {
|
|
76
|
+
if (typeof input === 'string')
|
|
77
|
+
return input;
|
|
78
|
+
if (input instanceof URL)
|
|
79
|
+
return input.toString();
|
|
80
|
+
return input.url;
|
|
81
|
+
}
|
|
82
|
+
/**
|
|
83
|
+
* Wrap a `fetch` so a response the AI route could not have produced throws
|
|
84
|
+
* {@link AiRouteNotMountedError} instead of being handed to a JSON/stream parser.
|
|
85
|
+
* Everything else — including the route's own error answers (401/429/502 JSON) —
|
|
86
|
+
* passes through unchanged for the existing handlers.
|
|
87
|
+
*/
|
|
88
|
+
export function guardAiRouteFetch(fetchImpl = fetch) {
|
|
89
|
+
return async (input, init) => {
|
|
90
|
+
const response = await fetchImpl(input, init);
|
|
91
|
+
const contentType = response.headers.get('content-type');
|
|
92
|
+
if (!isAiRouteAnswer(contentType)) {
|
|
93
|
+
throw new AiRouteNotMountedError(endpointOf(input), response.status, contentType);
|
|
94
|
+
}
|
|
95
|
+
return response;
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
//# sourceMappingURL=route-fetch.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"route-fetch.js","sourceRoot":"","sources":["../../../src/lib/ai/route-fetch.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA6BG;AAEH,yFAAyF;AACzF,MAAM,iBAAiB,GAAwB,IAAI,GAAG,CAAC,CAAC,kBAAkB,EAAE,mBAAmB,CAAC,CAAC,CAAA;AAEjG,iGAAiG;AACjG,MAAM,UAAU,WAAW,CAAC,WAAsC;IAChE,IAAI,CAAC,WAAW;QAAE,OAAO,IAAI,CAAA;IAC7B,MAAM,IAAI,GAAG,WAAW,CAAC,KAAK,CAAC,GAAG,CAAC,CAAC,CAAC,CAAC,EAAE,IAAI,EAAE,CAAC,WAAW,EAAE,IAAI,EAAE,CAAA;IAClE,OAAO,IAAI,CAAC,MAAM,GAAG,CAAC,CAAC,CAAC,CAAC,IAAI,CAAC,CAAC,CAAC,IAAI,CAAA;AACtC,CAAC;AAED,+EAA+E;AAC/E,MAAM,UAAU,eAAe,CAAC,WAAsC;IACpE,MAAM,KAAK,GAAG,WAAW,CAAC,WAAW,CAAC,CAAA;IACtC,OAAO,KAAK,KAAK,IAAI,IAAI,iBAAiB,CAAC,GAAG,CAAC,KAAK,CAAC,CAAA;AACvD,CAAC;AAED;;;;;;;GAOG;AACH,MAAM,OAAO,sBAAuB,SAAQ,KAAK;IAGpC;IACA;IACA;IAJO,IAAI,GAAG,wBAAwB,CAAA;IACjD,YACW,QAAgB,EAChB,MAAc,EACd,WAA0B;QAEnC,KAAK,CACH,6BAA6B,QAAQ,kCAAkC;YACrE,GAAG,WAAW,IAAI,iBAAiB,UAAU,MAAM,gDAAgD,CACtG,CAAA;QAPQ,aAAQ,GAAR,QAAQ,CAAQ;QAChB,WAAM,GAAN,MAAM,CAAQ;QACd,gBAAW,GAAX,WAAW,CAAe;IAMrC,CAAC;CACF;AAED,oGAAoG;AACpG,MAAM,UAAU,wBAAwB,CAAC,KAAc;IACrD,OAAO,CACL,KAAK,YAAY,sBAAsB;QACvC,CAAC,OAAO,KAAK,KAAK,QAAQ;YACxB,KAAK,KAAK,IAAI;YACb,KAA4B,CAAC,IAAI,KAAK,wBAAwB;YAC/D,OAAQ,KAAgC,CAAC,QAAQ,KAAK,QAAQ,CAAC,CAClE,CAAA;AACH,CAAC;AAED,oEAAoE;AACpE,SAAS,UAAU,CAAC,KAAwB;IAC1C,IAAI,OAAO,KAAK,KAAK,QAAQ;QAAE,OAAO,KAAK,CAAA;IAC3C,IAAI,KAAK,YAAY,GAAG;QAAE,OAAO,KAAK,CAAC,QAAQ,EAAE,CAAA;IACjD,OAAO,KAAK,CAAC,GAAG,CAAA;AAClB,CAAC;AAED;;;;;GAKG;AACH,MAAM,UAAU,iBAAiB,CAAC,YAA0B,KAAK;IAC/D,OAAO,KAAK,EAAE,KAAK,EAAE,IAAI,EAAE,EAAE;QAC3B,MAAM,QAAQ,GAAG,MAAM,SAAS,CAAC,KAAK,EAAE,IAAI,CAAC,CAAA;QAC7C,MAAM,WAAW,GAAG,QAAQ,CAAC,OAAO,CAAC,GAAG,CAAC,cAAc,CAAC,CAAA;QACxD,IAAI,CAAC,eAAe,CAAC,WAAW,CAAC,EAAE,CAAC;YAClC,MAAM,IAAI,sBAAsB,CAAC,UAAU,CAAC,KAAK,CAAC,EAAE,QAAQ,CAAC,MAAM,EAAE,WAAW,CAAC,CAAA;QACnF,CAAC;QACD,OAAO,QAAQ,CAAA;IACjB,CAAC,CAAA;AACH,CAAC","sourcesContent":["/**\n * The AI route's response-shape guard (EC-514 rider): ONE wrapping `fetch` shared by\n * both client call sites of the mounted route — the panel's chat transport\n * (`useChat` + `DefaultChatTransport`, which accepts a `fetch` option) and the\n * structured proxy in `createRouteProvider` — so \"the route is not mounted at this\n * path\" is a typed, nameable failure at both, instead of whatever each transport\n * makes of a page of HTML.\n *\n * Why this exists (measured, EC-514): with the EC-503 dev degrade active (studio\n * `dist` missing → the Vite dev mount is skipped) a `POST /api/ai` falls through to\n * the SPA shell — `200 text/html`. The chat transport treats a 200 as success and\n * parses the HTML as an event stream; the HTML carries no `data:` lines, so the\n * stream ends EMPTY and the panel shows the user's turn followed by nothing — no\n * error note, no assistant turn. A silent failure, the exact state AGENTS.md §4\n * forbids. The structured proxy's path is no better by construction: a 200 is\n * `response.ok`, so it calls `response.json()` on `<!DOCTYPE …` — a parse error\n * that names neither the path nor the cause.\n *\n * The contract this guard enforces is the route's OWN: `createAiRouteHandler` only\n * ever answers `application/json` (every non-stream branch, `route.ts`\n * `jsonResponse`) or the SDK's UI message stream (`text/event-stream`,\n * `createUIMessageStreamResponse`). Anything else — an HTML shell, a host's 404\n * page, a proxy's error page — was not produced by the route, so the request did\n * not reach a mounted route at that path. Positive detection (what the route DOES\n * answer) rather than a blocklist of page types, so a new kind of \"something else\"\n * cannot slip past as success.\n *\n * Bundle discipline: imports nothing from `ai` / `@ai-sdk/*` (pinned in\n * `bundle.test.ts`) — it is a plain `fetch` decorator.\n */\n\n/** Media types the mounted route answers with — JSON, or the SDK's UI message stream. */\nconst ROUTE_MEDIA_TYPES: ReadonlySet<string> = new Set(['application/json', 'text/event-stream'])\n\n/** The bare media type of a `content-type` header (`text/html; charset=utf-8` → `text/html`). */\nexport function mediaTypeOf(contentType: string | null | undefined): string | null {\n if (!contentType) return null\n const bare = contentType.split(';')[0]?.trim().toLowerCase() ?? ''\n return bare.length > 0 ? bare : null\n}\n\n/** True when a response's content type is one the AI route itself produces. */\nexport function isAiRouteAnswer(contentType: string | null | undefined): boolean {\n const media = mediaTypeOf(contentType)\n return media !== null && ROUTE_MEDIA_TYPES.has(media)\n}\n\n/**\n * Thrown by {@link guardAiRouteFetch} when the answer at `endpoint` was not produced\n * by the AI route — no route is mounted there (or something in front of it\n * answered). Carries what a developer needs to act: the path, the status, and the\n * content type that came back. The `message` is self-describing on purpose: the\n * tool executor (`tools.ts`) surfaces `error.message` verbatim as a tool step's\n * error text, and a bare \"unavailable\" there would hide the cause again.\n */\nexport class AiRouteNotMountedError extends Error {\n override readonly name = 'AiRouteNotMountedError'\n constructor(\n readonly endpoint: string,\n readonly status: number,\n readonly contentType: string | null,\n ) {\n super(\n `No AI route is mounted at ${endpoint}: the request was answered with ` +\n `${contentType ?? 'no content type'} (HTTP ${status}) instead of the route's JSON or event stream.`,\n )\n }\n}\n\n/** Type guard for {@link AiRouteNotMountedError} (works across bundle copies — checks the name). */\nexport function isAiRouteNotMountedError(error: unknown): error is AiRouteNotMountedError {\n return (\n error instanceof AiRouteNotMountedError ||\n (typeof error === 'object' &&\n error !== null &&\n (error as { name?: unknown }).name === 'AiRouteNotMountedError' &&\n typeof (error as { endpoint?: unknown }).endpoint === 'string')\n )\n}\n\n/** The path a `fetch` input names (string, `URL`, or `Request`). */\nfunction endpointOf(input: RequestInfo | URL): string {\n if (typeof input === 'string') return input\n if (input instanceof URL) return input.toString()\n return input.url\n}\n\n/**\n * Wrap a `fetch` so a response the AI route could not have produced throws\n * {@link AiRouteNotMountedError} instead of being handed to a JSON/stream parser.\n * Everything else — including the route's own error answers (401/429/502 JSON) —\n * passes through unchanged for the existing handlers.\n */\nexport function guardAiRouteFetch(fetchImpl: typeof fetch = fetch): typeof fetch {\n return async (input, init) => {\n const response = await fetchImpl(input, init)\n const contentType = response.headers.get('content-type')\n if (!isAiRouteAnswer(contentType)) {\n throw new AiRouteNotMountedError(endpointOf(input), response.status, contentType)\n }\n return response\n }\n}\n"]}
|
|
@@ -1,31 +1,14 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* `createRouteProvider` — the CLIENT-side {@link AiProvider} the studio runs in the
|
|
3
|
-
* browser (EC-199). It is a thin proxy that forwards `generateStructured` to the
|
|
4
|
-
* host's mounted {@link createAiRouteHandler} endpoint over `fetch`. The provider
|
|
5
|
-
* KEY never touches the browser: the key-holding {@link createAnthropicProvider}
|
|
6
|
-
* lives only inside the server route; the studio's `defineStudio({ ai: { provider } })`
|
|
7
|
-
* gets this route provider (or the FakeProvider for a keyless playground), so the
|
|
8
|
-
* whole authoring loop — `applyContentWithShape` included — proxies its model calls
|
|
9
|
-
* through the session-gated route while the tools (search / list / apply) execute
|
|
10
|
-
* client-side with the user's own session. It exposes NO `languageModel()` — the
|
|
11
|
-
* conversational loop talks to the route's native chat branch directly (EC-334,
|
|
12
|
-
* `useChat` + `DefaultChatTransport`), not through this provider; the provider
|
|
13
|
-
* only NAMES that target ({@link AiChatTarget}) so endpoint + session headers are
|
|
14
|
-
* configured once.
|
|
15
|
-
*
|
|
16
|
-
* Bundle-safe by construction: this module imports nothing from `ai` / `@ai-sdk/*`
|
|
17
|
-
* (it only uses `fetch`), so it stays off the AI-SDK dependency edge the bundle test
|
|
18
|
-
* pins — the route provider can sit on the studio critical path without pulling the
|
|
19
|
-
* SDK. The capability gate passes: the route only ever fronts a gate-checked provider
|
|
20
|
-
* (validated at the server mount), so the proxy declares both required capabilities.
|
|
21
|
-
*/
|
|
22
1
|
import type { AiProvider } from './types.js';
|
|
23
2
|
/** How extra request headers are supplied — a static set or a (possibly async) thunk. */
|
|
24
3
|
export type RouteHeaders = HeadersInit | (() => HeadersInit | undefined | Promise<HeadersInit | undefined>);
|
|
25
4
|
export interface RouteProviderOptions {
|
|
26
5
|
/** The mounted route path/URL (default `/api/ai`). */
|
|
27
6
|
endpoint?: string;
|
|
28
|
-
/**
|
|
7
|
+
/**
|
|
8
|
+
* `fetch` implementation (default the global). Injected in tests. Always wrapped
|
|
9
|
+
* by {@link guardAiRouteFetch} (EC-514): an answer the route could not have
|
|
10
|
+
* produced — the SPA shell, a host 404 page — throws `AiRouteNotMountedError`.
|
|
11
|
+
*/
|
|
29
12
|
fetchImpl?: typeof fetch;
|
|
30
13
|
/**
|
|
31
14
|
* Extra headers per request — e.g. the studio session token the route's `getSession`
|
|
@@ -1,3 +1,25 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `createRouteProvider` — the CLIENT-side {@link AiProvider} the studio runs in the
|
|
3
|
+
* browser (EC-199). It is a thin proxy that forwards `generateStructured` to the
|
|
4
|
+
* host's mounted {@link createAiRouteHandler} endpoint over `fetch`. The provider
|
|
5
|
+
* KEY never touches the browser: the key-holding {@link createAnthropicProvider}
|
|
6
|
+
* lives only inside the server route; the studio's `defineStudio({ ai: { provider } })`
|
|
7
|
+
* gets this route provider (or the FakeProvider for a keyless playground), so the
|
|
8
|
+
* whole authoring loop — `applyContentWithShape` included — proxies its model calls
|
|
9
|
+
* through the session-gated route while the tools (search / list / apply) execute
|
|
10
|
+
* client-side with the user's own session. It exposes NO `languageModel()` — the
|
|
11
|
+
* conversational loop talks to the route's native chat branch directly (EC-334,
|
|
12
|
+
* `useChat` + `DefaultChatTransport`), not through this provider; the provider
|
|
13
|
+
* only NAMES that target ({@link AiChatTarget}) so endpoint + session headers are
|
|
14
|
+
* configured once.
|
|
15
|
+
*
|
|
16
|
+
* Bundle-safe by construction: this module imports nothing from `ai` / `@ai-sdk/*`
|
|
17
|
+
* (it only uses `fetch`), so it stays off the AI-SDK dependency edge the bundle test
|
|
18
|
+
* pins — the route provider can sit on the studio critical path without pulling the
|
|
19
|
+
* SDK. The capability gate passes: the route only ever fronts a gate-checked provider
|
|
20
|
+
* (validated at the server mount), so the proxy declares both required capabilities.
|
|
21
|
+
*/
|
|
22
|
+
import { guardAiRouteFetch } from './route-fetch.js';
|
|
1
23
|
/** Default mount path a consuming studio app exposes for {@link createAiRouteHandler}. */
|
|
2
24
|
export const DEFAULT_AI_ENDPOINT = '/api/ai';
|
|
3
25
|
/**
|
|
@@ -44,7 +66,7 @@ async function errorCode(response) {
|
|
|
44
66
|
*/
|
|
45
67
|
export function createRouteProvider(options = {}) {
|
|
46
68
|
const endpoint = options.endpoint ?? DEFAULT_AI_ENDPOINT;
|
|
47
|
-
const fetchImpl = options.fetchImpl ?? fetch;
|
|
69
|
+
const fetchImpl = guardAiRouteFetch(options.fetchImpl ?? fetch);
|
|
48
70
|
async function post(body) {
|
|
49
71
|
const extra = await resolveHeaders(options.headers);
|
|
50
72
|
const headers = new Headers(extra);
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"route-provider.js","sourceRoot":"","sources":["../../../src/lib/ai/route-provider.ts"],"names":[],"mappings":"
|
|
1
|
+
{"version":3,"file":"route-provider.js","sourceRoot":"","sources":["../../../src/lib/ai/route-provider.ts"],"names":[],"mappings":"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","sourcesContent":["/**\n * `createRouteProvider` — the CLIENT-side {@link AiProvider} the studio runs in the\n * browser (EC-199). It is a thin proxy that forwards `generateStructured` to the\n * host's mounted {@link createAiRouteHandler} endpoint over `fetch`. The provider\n * KEY never touches the browser: the key-holding {@link createAnthropicProvider}\n * lives only inside the server route; the studio's `defineStudio({ ai: { provider } })`\n * gets this route provider (or the FakeProvider for a keyless playground), so the\n * whole authoring loop — `applyContentWithShape` included — proxies its model calls\n * through the session-gated route while the tools (search / list / apply) execute\n * client-side with the user's own session. It exposes NO `languageModel()` — the\n * conversational loop talks to the route's native chat branch directly (EC-334,\n * `useChat` + `DefaultChatTransport`), not through this provider; the provider\n * only NAMES that target ({@link AiChatTarget}) so endpoint + session headers are\n * configured once.\n *\n * Bundle-safe by construction: this module imports nothing from `ai` / `@ai-sdk/*`\n * (it only uses `fetch`), so it stays off the AI-SDK dependency edge the bundle test\n * pins — the route provider can sit on the studio critical path without pulling the\n * SDK. The capability gate passes: the route only ever fronts a gate-checked provider\n * (validated at the server mount), so the proxy declares both required capabilities.\n */\nimport { guardAiRouteFetch } from './route-fetch'\nimport type {\n AiProvider,\n AiProviderCapabilities,\n GenerateStructuredRequest,\n GenerateStructuredResult,\n} from './types'\n\n/** How extra request headers are supplied — a static set or a (possibly async) thunk. */\nexport type RouteHeaders =\n | HeadersInit\n | (() => HeadersInit | undefined | Promise<HeadersInit | undefined>)\n\nexport interface RouteProviderOptions {\n /** The mounted route path/URL (default `/api/ai`). */\n endpoint?: string\n /**\n * `fetch` implementation (default the global). Injected in tests. Always wrapped\n * by {@link guardAiRouteFetch} (EC-514): an answer the route could not have\n * produced — the SPA shell, a host 404 page — throws `AiRouteNotMountedError`.\n */\n fetchImpl?: typeof fetch\n /**\n * Extra headers per request — e.g. the studio session token the route's `getSession`\n * checks. A thunk is re-evaluated on every call so a refreshed token is picked up.\n */\n headers?: RouteHeaders\n /** Provider id recorded in provenance (default `route`). */\n id?: string\n /** Model id recorded in provenance (the downstream model, when the host fixes one). */\n model?: string\n}\n\n/** Default mount path a consuming studio app exposes for {@link createAiRouteHandler}. */\nexport const DEFAULT_AI_ENDPOINT = '/api/ai'\n\n/**\n * The chat transport target (EC-334): where the panel's `useChat` posts its\n * native `{ messages, context }` requests, and which extra headers ride along\n * (the session token thunk). `createRouteProvider` exposes its own endpoint +\n * headers here, so the ONE configured provider is the single source of truth\n * for both the structured proxy and the chat transport.\n */\nexport interface AiChatTarget {\n endpoint: string\n headers?: RouteHeaders\n}\n\n/** An {@link AiProvider} that also names the chat transport target it fronts. */\nexport interface RouteAiProvider extends AiProvider {\n readonly chatTarget: AiChatTarget\n}\n\n/**\n * The chat target behind a configured provider: the route provider's own\n * endpoint + headers, or the default mount path for any other provider (the\n * chat loop always talks to the mounted route — a missing mount fails loud as\n * a visible route error in the panel, never silently).\n */\nexport function resolveChatTarget(provider: AiProvider | null): AiChatTarget {\n const candidate = (provider as Partial<RouteAiProvider> | null)?.chatTarget\n if (candidate && typeof candidate.endpoint === 'string') return candidate\n return { endpoint: DEFAULT_AI_ENDPOINT }\n}\n\nasync function resolveHeaders(headers: RouteHeaders | undefined): Promise<HeadersInit | undefined> {\n if (typeof headers === 'function') return headers()\n return headers\n}\n\n/** A structured error thrown when the route rejects (surfaced by the chat UI). */\nexport class AiRouteError extends Error {\n constructor(\n readonly status: number,\n readonly code: string,\n ) {\n super(`AI route request failed (${status}: ${code})`)\n this.name = 'AiRouteError'\n }\n}\n\nasync function errorCode(response: Response): Promise<string> {\n try {\n const body = (await response.json()) as { error?: unknown }\n return typeof body.error === 'string' ? body.error : 'request-failed'\n } catch {\n return 'request-failed'\n }\n}\n\n/**\n * Build a route-backed {@link AiProvider}. Deterministic given a deterministic route\n * (so the FakeProvider mounted behind the route drives the whole client loop in the\n * playground). Never holds a key; never imports the AI SDK.\n */\nexport function createRouteProvider(options: RouteProviderOptions = {}): RouteAiProvider {\n const endpoint = options.endpoint ?? DEFAULT_AI_ENDPOINT\n const fetchImpl = guardAiRouteFetch(options.fetchImpl ?? fetch)\n\n async function post(body: unknown): Promise<Response> {\n const extra = await resolveHeaders(options.headers)\n const headers = new Headers(extra)\n headers.set('content-type', 'application/json')\n return fetchImpl(endpoint, { method: 'POST', headers, body: JSON.stringify(body) })\n }\n\n return {\n id: options.id ?? 'route',\n ...(options.model !== undefined ? { model: options.model } : {}),\n chatTarget: { endpoint, ...(options.headers !== undefined ? { headers: options.headers } : {}) },\n capabilities: (): AiProviderCapabilities => ({ toolCalling: true, structuredOutput: true }),\n\n async generateStructured(\n request: GenerateStructuredRequest,\n ): Promise<GenerateStructuredResult> {\n const response = await post({ kind: 'generate', request })\n if (!response.ok) throw new AiRouteError(response.status, await errorCode(response))\n return (await response.json()) as GenerateStructuredResult\n },\n }\n}\n"]}
|
package/dist/lib/ai/route.js
CHANGED
|
@@ -50,10 +50,13 @@ export function createAiRouteHandler(options) {
|
|
|
50
50
|
const maxPerMinute = options.limits?.maxRequestsPerMinute;
|
|
51
51
|
const buckets = new Map();
|
|
52
52
|
const projectKey = options.projectKey ?? (() => 'global');
|
|
53
|
+
// EC-602: the ONLY clock read in this module — injected, never `Date.now()` in
|
|
54
|
+
// the decision below.
|
|
55
|
+
const clock = options.now ?? Date.now;
|
|
53
56
|
function rateLimited(key) {
|
|
54
57
|
if (maxPerMinute === undefined || maxPerMinute <= 0)
|
|
55
58
|
return false;
|
|
56
|
-
const now =
|
|
59
|
+
const now = clock();
|
|
57
60
|
const bucket = buckets.get(key);
|
|
58
61
|
if (!bucket || now - bucket.windowStart >= 60_000) {
|
|
59
62
|
buckets.set(key, { windowStart: now, count: 1 });
|
package/dist/lib/ai/route.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"route.js","sourceRoot":"","sources":["../../../src/lib/ai/route.ts"],"names":[],"mappings":"AAqCA,OAAO,EAAE,mBAAmB,EAAE,MAAM,SAAS,CAAA;AAE7C,SAAS,YAAY,CAAC,KAAc,EAAE,MAAc;IAClD,OAAO,IAAI,QAAQ,CAAC,IAAI,CAAC,SAAS,CAAC,KAAK,CAAC,EAAE;QACzC,MAAM;QACN,OAAO,EAAE,EAAE,cAAc,EAAE,kBAAkB,EAAE;KAChD,CAAC,CAAA;AACJ,CAAC;AAcD,2FAA2F;AAC3F,SAAS,iBAAiB,CAAC,IAAa;IACtC,IAAI,OAAO,IAAI,KAAK,QAAQ,IAAI,IAAI,KAAK,IAAI;QAAE,OAAO,IAAI,CAAA;IAC1D,MAAM,OAAO,GAAI,IAA8B,CAAC,OAAO,CAAA;IACvD,IAAI,OAAO,OAAO,KAAK,QAAQ,IAAI,OAAO,KAAK,IAAI;QAAE,OAAO,IAAI,CAAA;IAChE,MAAM,EAAE,MAAM,EAAE,MAAM,EAAE,GAAG,OAAiD,CAAA;IAC5E,IAAI,OAAO,MAAM,KAAK,QAAQ,IAAI,OAAO,MAAM,KAAK,QAAQ,IAAI,MAAM,KAAK,IAAI;QAAE,OAAO,IAAI,CAAA;IAC5F,OAAO,OAAoC,CAAA;AAC7C,CAAC;AAKD;;;;;;GAMG;AACH,MAAM,UAAU,oBAAoB,CAAC,OAAgB;IACnD,IAAI,OAAO,KAAK,SAAS,IAAI,OAAO,KAAK,IAAI;QAAE,OAAO,EAAE,EAAE,EAAE,IAAI,EAAE,CAAA;IAClE,MAAM,QAAQ,GACZ,OAAO,OAAO,KAAK,QAAQ;QACzB,CAAC,CAAC,CAAC,OAAO,CAAC;QACX,CAAC,CAAC,KAAK,CAAC,OAAO,CAAC,OAAO,CAAC,IAAI,OAAO,CAAC,KAAK,CAAC,CAAC,OAAO,EAAE,EAAE,CAAC,OAAO,OAAO,KAAK,QAAQ,CAAC;YACjF,CAAC,CAAE,OAAoB;YACvB,CAAC,CAAC,IAAI,CAAA;IACZ,IAAI,QAAQ,KAAK,IAAI;QAAE,OAAO,EAAE,EAAE,EAAE,KAAK,EAAE,CAAA;IAC3C,MAAM,MAAM,GAAG,QAAQ;SACpB,GAAG,CAAC,CAAC,OAAO,EAAE,EAAE,CAAC,OAAO,CAAC,IAAI,EAAE,CAAC;SAChC,MAAM,CAAC,CAAC,OAAO,EAAE,EAAE,CAAC,OAAO,CAAC,MAAM,GAAG,CAAC,CAAC;SACvC,IAAI,CAAC,MAAM,CAAC,CAAA;IACf,OAAO,EAAE,EAAE,EAAE,IAAI,EAAE,GAAG,CAAC,MAAM,CAAC,MAAM,GAAG,CAAC,CAAC,CAAC,CAAC,EAAE,MAAM,EAAE,CAAC,CAAC,CAAC,EAAE,CAAC,EAAE,CAAA;AAC/D,CAAC;AAED;;;;;GAKG;AACH,MAAM,UAAU,oBAAoB,CAAC,OAA8B;IACjE,MAAM,YAAY,GAAG,OAAO,CAAC,MAAM,EAAE,oBAAoB,CAAA;IACzD,MAAM,OAAO,GAAG,IAAI,GAAG,EAAsB,CAAA;IAC7C,MAAM,UAAU,GAAG,OAAO,CAAC,UAAU,IAAI,CAAC,GAAG,EAAE,CAAC,QAAQ,CAAC,CAAA;IAEzD,SAAS,WAAW,CAAC,GAAW;QAC9B,IAAI,YAAY,KAAK,SAAS,IAAI,YAAY,IAAI,CAAC;YAAE,OAAO,KAAK,CAAA;QACjE,MAAM,GAAG,GAAG,IAAI,CAAC,GAAG,EAAE,CAAA;QACtB,MAAM,MAAM,GAAG,OAAO,CAAC,GAAG,CAAC,GAAG,CAAC,CAAA;QAC/B,IAAI,CAAC,MAAM,IAAI,GAAG,GAAG,MAAM,CAAC,WAAW,IAAI,MAAM,EAAE,CAAC;YAClD,OAAO,CAAC,GAAG,CAAC,GAAG,EAAE,EAAE,WAAW,EAAE,GAAG,EAAE,KAAK,EAAE,CAAC,EAAE,CAAC,CAAA;YAChD,OAAO,KAAK,CAAA;QACd,CAAC;QACD,MAAM,CAAC,KAAK,IAAI,CAAC,CAAA;QACjB,OAAO,MAAM,CAAC,KAAK,GAAG,YAAY,CAAA;IACpC,CAAC;IAED,OAAO,KAAK,EAAE,OAAgB,EAAqB,EAAE;QACnD,gFAAgF;QAChF,+EAA+E;QAC/E,IAAI,OAAyB,CAAA;QAC7B,IAAI,CAAC;YACH,OAAO,GAAG,MAAM,OAAO,CAAC,UAAU,CAAC,OAAO,CAAC,CAAA;QAC7C,CAAC;QAAC,MAAM,CAAC;YACP,OAAO,YAAY,CAAC,EAAE,KAAK,EAAE,cAAc,EAAE,EAAE,GAAG,CAAC,CAAA;QACrD,CAAC;QACD,IAAI,CAAC,OAAO;YAAE,OAAO,YAAY,CAAC,EAAE,KAAK,EAAE,cAAc,EAAE,EAAE,GAAG,CAAC,CAAA;QAEjE,aAAa;QACb,IAAI,OAAO,CAAC,MAAM,KAAK,MAAM;YAAE,OAAO,YAAY,CAAC,EAAE,KAAK,EAAE,oBAAoB,EAAE,EAAE,GAAG,CAAC,CAAA;QAExF,4CAA4C;QAC5C,IAAI,WAAW,CAAC,UAAU,CAAC,OAAO,CAAC,CAAC;YAAE,OAAO,YAAY,CAAC,EAAE,KAAK,EAAE,cAAc,EAAE,EAAE,GAAG,CAAC,CAAA;QAEzF,2CAA2C;QAC3C,IAAI,IAAa,CAAA;QACjB,IAAI,CAAC;YACH,IAAI,GAAG,MAAM,OAAO,CAAC,IAAI,EAAE,CAAA;QAC7B,CAAC;QAAC,MAAM,CAAC;YACP,OAAO,YAAY,CAAC,EAAE,KAAK,EAAE,cAAc,EAAE,EAAE,GAAG,CAAC,CAAA;QACrD,CAAC;QACD,IAAI,OAAO,IAAI,KAAK,QAAQ,IAAI,IAAI,KAAK,IAAI,EAAE,CAAC;YAC9C,OAAO,YAAY,CAAC,EAAE,KAAK,EAAE,cAAc,EAAE,EAAE,GAAG,CAAC,CAAA;QACrD,CAAC;QAED,+EAA+E;QAC/E,iEAAiE;QACjE,IAAK,IAA2B,CAAC,IAAI,KAAK,UAAU,EAAE,CAAC;YACrD,MAAM,QAAQ,GAAG,iBAAiB,CAAC,IAAI,CAAC,CAAA;YACxC,IAAI,CAAC,QAAQ,EAAE,CAAC;gBACd,OAAO,YAAY,CACjB,EAAE,KAAK,EAAE,iBAAiB,EAAE,OAAO,EAAE,wCAAwC,EAAE,EAC/E,GAAG,CACJ,CAAA;YACH,CAAC;YACD,IAAI,CAAC;gBACH,MAAM,MAAM,GAAG,MAAM,OAAO,CAAC,QAAQ,CAAC,kBAAkB,CAAC,QAAQ,CAAC,CAAA;gBAClE,OAAO,YAAY,CAAC,MAAM,EAAE,GAAG,CAAC,CAAA;YAClC,CAAC;YAAC,MAAM,CAAC;gBACP,OAAO,YAAY,CAAC,EAAE,KAAK,EAAE,gBAAgB,EAAE,EAAE,GAAG,CAAC,CAAA;YACvD,CAAC;QACH,CAAC;QAED,0EAA0E;QAC1E,MAAM,WAAW,GAAI,IAA+B,CAAC,QAAQ,CAAA;QAC7D,IAAI,CAAC,KAAK,CAAC,OAAO,CAAC,WAAW,CAAC,IAAI,WAAW,CAAC,MAAM,KAAK,CAAC,EAAE,CAAC;YAC5D,OAAO,YAAY,CACjB;gBACE,KAAK,EAAE,iBAAiB;gBACxB,OAAO,EAAE,qEAAqE;aAC/E,EACD,GAAG,CACJ,CAAA;QACH,CAAC;QAED,MAAM,SAAS,GAAG,oBAAoB,CAAE,IAA8B,CAAC,OAAO,CAAC,CAAA;QAC/E,IAAI,CAAC,SAAS,CAAC,EAAE,EAAE,CAAC;YAClB,OAAO,YAAY,CACjB,EAAE,KAAK,EAAE,iBAAiB,EAAE,OAAO,EAAE,sCAAsC,EAAE,EAC7E,GAAG,CACJ,CAAA;QACH,CAAC;QAED,IAAI,CAAC,OAAO,CAAC,QAAQ,CAAC,aAAa,EAAE,CAAC;YACpC,OAAO,YAAY,CAAC,EAAE,KAAK,EAAE,wBAAwB,EAAE,EAAE,GAAG,CAAC,CAAA;QAC/D,CAAC;QAED,+EAA+E;QAC/E,6EAA6E;QAC7E,yEAAyE;QACzE,MAAM,EACJ,kBAAkB,EAClB,sBAAsB,EACtB,UAAU,EACV,6BAA6B,EAC7B,iBAAiB,GAClB,GAAG,MAAM,MAAM,CAAC,IAAI,CAAC,CAAA;QAEtB,0EAA0E;QAC1E,0EAA0E;QAC1E,IAAI,QAAQ,CAAA;QACZ,IAAI,CAAC;YACH,QAAQ,GAAG,MAAM,kBAAkB,CAAC,EAAE,QAAQ,EAAE,WAAW,EAAE,CAAC,CAAA;QAChE,CAAC;QAAC,MAAM,CAAC;YACP,OAAO,YAAY,CACjB,EAAE,KAAK,EAAE,iBAAiB,EAAE,OAAO,EAAE,oCAAoC,EAAE,EAC3E,GAAG,CACJ,CAAA;QACH,CAAC;QAED,IAAI,CAAC;YACH,MAAM,KAAK,GAAG,MAAM,OAAO,CAAC,QAAQ,CAAC,aAAa,EAAE,CAAA;YACpD,MAAM,MAAM,GAAG,UAAU,CAAC;gBACxB,KAAK;gBACL,GAAG,CAAC,SAAS,CAAC,MAAM,KAAK,SAAS,CAAC,CAAC,CAAC,EAAE,MAAM,EAAE,SAAS,CAAC,MAAM,EAAE,CAAC,CAAC,CAAC,EAAE,CAAC;gBACvE,QAAQ,EAAE,MAAM,sBAAsB,CAAC,QAAQ,CAAC;gBAChD,sEAAsE;gBACtE,0EAA0E;gBAC1E,sEAAsE;gBACtE,oDAAoD;gBACpD,KAAK,EAAE,mBAAmB;aAC3B,CAAC,CAAA;YACF,OAAO,6BAA6B,CAAC;gBACnC,MAAM,EAAE,iBAAiB,CAAC,EAAE,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,gBAAgB,EAAE,QAAQ,EAAE,CAAC;aACjF,CAAC,CAAA;QACJ,CAAC;QAAC,MAAM,CAAC;YACP,OAAO,YAAY,CAAC,EAAE,KAAK,EAAE,gBAAgB,EAAE,EAAE,GAAG,CAAC,CAAA;QACvD,CAAC;IACH,CAAC,CAAA;AACH,CAAC","sourcesContent":["/**\n * Server-route factory (EC-331): the session-gated endpoint the consuming studio\n * app mounts. Key custody = host route (never the browser, never Convex): the\n * `provider` holds the host-env key and this handler is a THIN model proxy — NO data\n * access, NO service credentials, NO server-side tool execution.\n *\n * Framework-agnostic on purpose: it returns a Web `Request → Response` handler the\n * host wires into whatever server entry it has (`createAiDevPlugin` in dev,\n * `toNodeHandler` on a Vercel Node function, any fetch handler).\n *\n * Session-gated: `getSession` (host-wired, reading its own auth — this module never\n * touches `lib/auth`) must return a session for authenticated callers; anonymous\n * callers are rejected with 401 BEFORE any stream byte. Plus a soft, in-memory\n * per-project rate counter (spend guard) — also the backstop for the SDK's\n * client-driven tool-call continuations, each of which is a fresh request here.\n *\n * The route serves TWO model operations — both thin proxies, neither with any data\n * access:\n * - `{ kind: 'generate', request }` → `provider.generateStructured` (JSON) — the\n * proxy `applyContentWithShape` / `translateDocument` run CLIENT-side against,\n * so the key never touches the browser;\n * - `{ messages: UIMessage[], context? }` (EC-334, kind-less — the shape the AI\n * SDK's `DefaultChatTransport` posts) → the SDK's native streaming chat\n * protocol: `validateUIMessages` → `streamText` over\n * `provider.languageModel()` with {@link chatToolDefinitions} declared WITHOUT\n * `execute` (every tool call forwards to the client, which executes it against\n * the operations layer with the user's own session) →\n * `createUIMessageStreamResponse` + `toUIMessageStream` (the non-deprecated\n * pair — `result.toUIMessageStreamResponse()` dies in SDK v8). The `system`\n * prompt is assembled from the request's `context` sections; conversation\n * history rides natively in `messages`.\n *\n * BREAKING (EC-334, ratified): the legacy `{ kind: 'chat', messages:\n * AiChatMessage[] }` wire is gone — route factory and client ship lockstep in\n * `@elytracms/studio`, so consumers re-mount the same factory on upgrade.\n */\nimport type { AiRouteHandler, AiRouteHandlerOptions, AiSession, GenerateStructuredRequest } from './types'\nimport { chatToolDefinitions } from './tools'\n\nfunction jsonResponse(value: unknown, status: number): Response {\n return new Response(JSON.stringify(value), {\n status,\n headers: { 'content-type': 'application/json' },\n })\n}\n\ninterface RateBucket {\n windowStart: number\n count: number\n}\n\n/**\n * The wire discriminator for the structured-generation branch. The native chat\n * branch (EC-334) is deliberately kind-less: the SDK's `DefaultChatTransport`\n * posts `{ messages, … }` with no `kind`.\n */\nexport type AiRouteKind = 'generate'\n\n/** Structurally validate a {@link GenerateStructuredRequest}, or `null` when malformed. */\nfunction parseGenerateBody(body: unknown): GenerateStructuredRequest | null {\n if (typeof body !== 'object' || body === null) return null\n const request = (body as { request?: unknown }).request\n if (typeof request !== 'object' || request === null) return null\n const { prompt, schema } = request as { prompt?: unknown; schema?: unknown }\n if (typeof prompt !== 'string' || typeof schema !== 'object' || schema === null) return null\n return request as GenerateStructuredRequest\n}\n\n/** The outcome of folding the request `context` into a system prompt. */\nexport type AssembledSystem = { ok: true; system?: string } | { ok: false }\n\n/**\n * Fold the request-body `context` into the system prompt (EC-334). The client sends\n * `context` as one string or ordered string sections (collections-catalog summary,\n * client-resolved knowledge, EC-378's current-document block) — the route only\n * ASSEMBLES them; it resolves no data itself (zero-data-access proxy). Malformed\n * context is a structural 400, never silently dropped (validation-first).\n */\nexport function assembleSystemPrompt(context: unknown): AssembledSystem {\n if (context === undefined || context === null) return { ok: true }\n const sections =\n typeof context === 'string'\n ? [context]\n : Array.isArray(context) && context.every((section) => typeof section === 'string')\n ? (context as string[])\n : null\n if (sections === null) return { ok: false }\n const system = sections\n .map((section) => section.trim())\n .filter((section) => section.length > 0)\n .join('\\n\\n')\n return { ok: true, ...(system.length > 0 ? { system } : {}) }\n}\n\n/**\n * Build the mounted route handler. The returned function is a plain\n * `(Request) => Promise<Response>` — session-gated, soft-rate-limited, and a thin\n * proxy to the provider. Never throws to the caller: every failure is a structured\n * JSON response with an appropriate status.\n */\nexport function createAiRouteHandler(options: AiRouteHandlerOptions): AiRouteHandler {\n const maxPerMinute = options.limits?.maxRequestsPerMinute\n const buckets = new Map<string, RateBucket>()\n const projectKey = options.projectKey ?? (() => 'global')\n\n function rateLimited(key: string): boolean {\n if (maxPerMinute === undefined || maxPerMinute <= 0) return false\n const now = Date.now()\n const bucket = buckets.get(key)\n if (!bucket || now - bucket.windowStart >= 60_000) {\n buckets.set(key, { windowStart: now, count: 1 })\n return false\n }\n bucket.count += 1\n return bucket.count > maxPerMinute\n }\n\n return async (request: Request): Promise<Response> => {\n // 1. Session gate — reject anonymous callers (the route holds spend, not data).\n // Position is FROZEN: the 401 must precede any stream byte (EC-371/EC-334).\n let session: AiSession | null\n try {\n session = await options.getSession(request)\n } catch {\n return jsonResponse({ error: 'unauthorized' }, 401)\n }\n if (!session) return jsonResponse({ error: 'unauthorized' }, 401)\n\n // 2. Method.\n if (request.method !== 'POST') return jsonResponse({ error: 'method-not-allowed' }, 405)\n\n // 3. Soft per-project rate / spend counter.\n if (rateLimited(projectKey(request))) return jsonResponse({ error: 'rate-limited' }, 429)\n\n // 4. Body → the requested model operation.\n let body: unknown\n try {\n body = await request.json()\n } catch {\n return jsonResponse({ error: 'invalid-json' }, 400)\n }\n if (typeof body !== 'object' || body === null) {\n return jsonResponse({ error: 'invalid-json' }, 400)\n }\n\n // 5a. Structured generation — the thin proxy that lets `applyContentWithShape`\n // run client-side without the key ever reaching the browser.\n if ((body as { kind?: unknown }).kind === 'generate') {\n const generate = parseGenerateBody(body)\n if (!generate) {\n return jsonResponse(\n { error: 'invalid-request', message: 'expected { kind: \"generate\", request }' },\n 400,\n )\n }\n try {\n const result = await options.provider.generateStructured(generate)\n return jsonResponse(result, 200)\n } catch {\n return jsonResponse({ error: 'provider-error' }, 502)\n }\n }\n\n // 5b. Native chat branch (EC-334): `{ messages: UIMessage[], context? }`.\n const rawMessages = (body as { messages?: unknown }).messages\n if (!Array.isArray(rawMessages) || rawMessages.length === 0) {\n return jsonResponse(\n {\n error: 'invalid-request',\n message: 'expected { kind: \"generate\", request } or { messages: UIMessage[] }',\n },\n 400,\n )\n }\n\n const assembled = assembleSystemPrompt((body as { context?: unknown }).context)\n if (!assembled.ok) {\n return jsonResponse(\n { error: 'invalid-request', message: 'context must be a string or string[]' },\n 400,\n )\n }\n\n if (!options.provider.languageModel) {\n return jsonResponse({ error: 'provider-cannot-stream' }, 501)\n }\n\n // The SDK loads lazily INSIDE the handler (the anthropic-provider discipline):\n // the client-imported barrel re-exports this module, and a static edge would\n // put the AI SDK on the studio critical path (pinned in bundle.test.ts).\n const {\n validateUIMessages,\n convertToModelMessages,\n streamText,\n createUIMessageStreamResponse,\n toUIMessageStream,\n } = await import('ai')\n\n // Structural UIMessage validation (id/role/parts). Tool parts validate by\n // shape — deep input validation belongs to the client executor (slice 2).\n let messages\n try {\n messages = await validateUIMessages({ messages: rawMessages })\n } catch {\n return jsonResponse(\n { error: 'invalid-request', message: 'expected { messages: UIMessage[] }' },\n 400,\n )\n }\n\n try {\n const model = await options.provider.languageModel()\n const result = streamText({\n model,\n ...(assembled.system !== undefined ? { system: assembled.system } : {}),\n messages: await convertToModelMessages(messages),\n // No `execute` on any tool — the SDK forwards each call to the client\n // (ratified trust boundary). Continuation is therefore client-driven: one\n // tool round-trip per REQUEST, throttled by the rate limit above; the\n // per-turn step cap lands client-side with slice 2.\n tools: chatToolDefinitions,\n })\n return createUIMessageStreamResponse({\n stream: toUIMessageStream({ stream: result.stream, originalMessages: messages }),\n })\n } catch {\n return jsonResponse({ error: 'provider-error' }, 502)\n }\n }\n}\n"]}
|
|
1
|
+
{"version":3,"file":"route.js","sourceRoot":"","sources":["../../../src/lib/ai/route.ts"],"names":[],"mappings":"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","sourcesContent":["/**\n * Server-route factory (EC-331): the session-gated endpoint the consuming studio\n * app mounts. Key custody = host route (never the browser, never Convex): the\n * `provider` holds the host-env key and this handler is a THIN model proxy — NO data\n * access, NO service credentials, NO server-side tool execution.\n *\n * Framework-agnostic on purpose: it returns a Web `Request → Response` handler the\n * host wires into whatever server entry it has (`createAiDevPlugin` in dev,\n * `toNodeHandler` on a Vercel Node function, any fetch handler).\n *\n * Session-gated: `getSession` (host-wired, reading its own auth — this module never\n * touches `lib/auth`) must return a session for authenticated callers; anonymous\n * callers are rejected with 401 BEFORE any stream byte. Plus a soft, in-memory\n * per-project rate counter (spend guard) — also the backstop for the SDK's\n * client-driven tool-call continuations, each of which is a fresh request here.\n *\n * The route serves TWO model operations — both thin proxies, neither with any data\n * access:\n * - `{ kind: 'generate', request }` → `provider.generateStructured` (JSON) — the\n * proxy `applyContentWithShape` / `translateDocument` run CLIENT-side against,\n * so the key never touches the browser;\n * - `{ messages: UIMessage[], context? }` (EC-334, kind-less — the shape the AI\n * SDK's `DefaultChatTransport` posts) → the SDK's native streaming chat\n * protocol: `validateUIMessages` → `streamText` over\n * `provider.languageModel()` with {@link chatToolDefinitions} declared WITHOUT\n * `execute` (every tool call forwards to the client, which executes it against\n * the operations layer with the user's own session) →\n * `createUIMessageStreamResponse` + `toUIMessageStream` (the non-deprecated\n * pair — `result.toUIMessageStreamResponse()` dies in SDK v8). The `system`\n * prompt is assembled from the request's `context` sections; conversation\n * history rides natively in `messages`.\n *\n * BREAKING (EC-334, ratified): the legacy `{ kind: 'chat', messages:\n * AiChatMessage[] }` wire is gone — route factory and client ship lockstep in\n * `@elytracms/studio`, so consumers re-mount the same factory on upgrade.\n */\nimport type { AiRouteHandler, AiRouteHandlerOptions, AiSession, GenerateStructuredRequest } from './types'\nimport { chatToolDefinitions } from './tools'\n\nfunction jsonResponse(value: unknown, status: number): Response {\n return new Response(JSON.stringify(value), {\n status,\n headers: { 'content-type': 'application/json' },\n })\n}\n\ninterface RateBucket {\n windowStart: number\n count: number\n}\n\n/**\n * The wire discriminator for the structured-generation branch. The native chat\n * branch (EC-334) is deliberately kind-less: the SDK's `DefaultChatTransport`\n * posts `{ messages, … }` with no `kind`.\n */\nexport type AiRouteKind = 'generate'\n\n/** Structurally validate a {@link GenerateStructuredRequest}, or `null` when malformed. */\nfunction parseGenerateBody(body: unknown): GenerateStructuredRequest | null {\n if (typeof body !== 'object' || body === null) return null\n const request = (body as { request?: unknown }).request\n if (typeof request !== 'object' || request === null) return null\n const { prompt, schema } = request as { prompt?: unknown; schema?: unknown }\n if (typeof prompt !== 'string' || typeof schema !== 'object' || schema === null) return null\n return request as GenerateStructuredRequest\n}\n\n/** The outcome of folding the request `context` into a system prompt. */\nexport type AssembledSystem = { ok: true; system?: string } | { ok: false }\n\n/**\n * Fold the request-body `context` into the system prompt (EC-334). The client sends\n * `context` as one string or ordered string sections (collections-catalog summary,\n * client-resolved knowledge, EC-378's current-document block) — the route only\n * ASSEMBLES them; it resolves no data itself (zero-data-access proxy). Malformed\n * context is a structural 400, never silently dropped (validation-first).\n */\nexport function assembleSystemPrompt(context: unknown): AssembledSystem {\n if (context === undefined || context === null) return { ok: true }\n const sections =\n typeof context === 'string'\n ? [context]\n : Array.isArray(context) && context.every((section) => typeof section === 'string')\n ? (context as string[])\n : null\n if (sections === null) return { ok: false }\n const system = sections\n .map((section) => section.trim())\n .filter((section) => section.length > 0)\n .join('\\n\\n')\n return { ok: true, ...(system.length > 0 ? { system } : {}) }\n}\n\n/**\n * Build the mounted route handler. The returned function is a plain\n * `(Request) => Promise<Response>` — session-gated, soft-rate-limited, and a thin\n * proxy to the provider. Never throws to the caller: every failure is a structured\n * JSON response with an appropriate status.\n */\nexport function createAiRouteHandler(options: AiRouteHandlerOptions): AiRouteHandler {\n const maxPerMinute = options.limits?.maxRequestsPerMinute\n const buckets = new Map<string, RateBucket>()\n const projectKey = options.projectKey ?? (() => 'global')\n // EC-602: the ONLY clock read in this module — injected, never `Date.now()` in\n // the decision below.\n const clock = options.now ?? Date.now\n\n function rateLimited(key: string): boolean {\n if (maxPerMinute === undefined || maxPerMinute <= 0) return false\n const now = clock()\n const bucket = buckets.get(key)\n if (!bucket || now - bucket.windowStart >= 60_000) {\n buckets.set(key, { windowStart: now, count: 1 })\n return false\n }\n bucket.count += 1\n return bucket.count > maxPerMinute\n }\n\n return async (request: Request): Promise<Response> => {\n // 1. Session gate — reject anonymous callers (the route holds spend, not data).\n // Position is FROZEN: the 401 must precede any stream byte (EC-371/EC-334).\n let session: AiSession | null\n try {\n session = await options.getSession(request)\n } catch {\n return jsonResponse({ error: 'unauthorized' }, 401)\n }\n if (!session) return jsonResponse({ error: 'unauthorized' }, 401)\n\n // 2. Method.\n if (request.method !== 'POST') return jsonResponse({ error: 'method-not-allowed' }, 405)\n\n // 3. Soft per-project rate / spend counter.\n if (rateLimited(projectKey(request))) return jsonResponse({ error: 'rate-limited' }, 429)\n\n // 4. Body → the requested model operation.\n let body: unknown\n try {\n body = await request.json()\n } catch {\n return jsonResponse({ error: 'invalid-json' }, 400)\n }\n if (typeof body !== 'object' || body === null) {\n return jsonResponse({ error: 'invalid-json' }, 400)\n }\n\n // 5a. Structured generation — the thin proxy that lets `applyContentWithShape`\n // run client-side without the key ever reaching the browser.\n if ((body as { kind?: unknown }).kind === 'generate') {\n const generate = parseGenerateBody(body)\n if (!generate) {\n return jsonResponse(\n { error: 'invalid-request', message: 'expected { kind: \"generate\", request }' },\n 400,\n )\n }\n try {\n const result = await options.provider.generateStructured(generate)\n return jsonResponse(result, 200)\n } catch {\n return jsonResponse({ error: 'provider-error' }, 502)\n }\n }\n\n // 5b. Native chat branch (EC-334): `{ messages: UIMessage[], context? }`.\n const rawMessages = (body as { messages?: unknown }).messages\n if (!Array.isArray(rawMessages) || rawMessages.length === 0) {\n return jsonResponse(\n {\n error: 'invalid-request',\n message: 'expected { kind: \"generate\", request } or { messages: UIMessage[] }',\n },\n 400,\n )\n }\n\n const assembled = assembleSystemPrompt((body as { context?: unknown }).context)\n if (!assembled.ok) {\n return jsonResponse(\n { error: 'invalid-request', message: 'context must be a string or string[]' },\n 400,\n )\n }\n\n if (!options.provider.languageModel) {\n return jsonResponse({ error: 'provider-cannot-stream' }, 501)\n }\n\n // The SDK loads lazily INSIDE the handler (the anthropic-provider discipline):\n // the client-imported barrel re-exports this module, and a static edge would\n // put the AI SDK on the studio critical path (pinned in bundle.test.ts).\n const {\n validateUIMessages,\n convertToModelMessages,\n streamText,\n createUIMessageStreamResponse,\n toUIMessageStream,\n } = await import('ai')\n\n // Structural UIMessage validation (id/role/parts). Tool parts validate by\n // shape — deep input validation belongs to the client executor (slice 2).\n let messages\n try {\n messages = await validateUIMessages({ messages: rawMessages })\n } catch {\n return jsonResponse(\n { error: 'invalid-request', message: 'expected { messages: UIMessage[] }' },\n 400,\n )\n }\n\n try {\n const model = await options.provider.languageModel()\n const result = streamText({\n model,\n ...(assembled.system !== undefined ? { system: assembled.system } : {}),\n messages: await convertToModelMessages(messages),\n // No `execute` on any tool — the SDK forwards each call to the client\n // (ratified trust boundary). Continuation is therefore client-driven: one\n // tool round-trip per REQUEST, throttled by the rate limit above; the\n // per-turn step cap lands client-side with slice 2.\n tools: chatToolDefinitions,\n })\n return createUIMessageStreamResponse({\n stream: toUIMessageStream({ stream: result.stream, originalMessages: messages }),\n })\n } catch {\n return jsonResponse({ error: 'provider-error' }, 502)\n }\n }\n}\n"]}
|
package/dist/lib/ai/shape.d.ts
CHANGED
|
@@ -40,7 +40,8 @@ export interface BuildTargetShapeInput {
|
|
|
40
40
|
}
|
|
41
41
|
/**
|
|
42
42
|
* Build the leash-aware target shape from a collection + candidates. Deterministic;
|
|
43
|
-
* never throws. Reuses `collectionJsonSchema` as the base
|
|
44
|
-
*
|
|
43
|
+
* never throws. Reuses `collectionJsonSchema` as the base (its `required` list), then
|
|
44
|
+
* gives every property its writer form (EC-556) or its id-enum leash, and strips
|
|
45
|
+
* excluded fields.
|
|
45
46
|
*/
|
|
46
47
|
export declare function buildTargetShape(input: BuildTargetShapeInput): TargetShape;
|
package/dist/lib/ai/shape.js
CHANGED
|
@@ -42,27 +42,16 @@
|
|
|
42
42
|
* accepts): a `date` property is `type: 'string'` + `format: 'date-time'` rather than
|
|
43
43
|
* the `['string','number']` union the general projection carries, because the model is
|
|
44
44
|
* asked to author an ISO instant (EC-472) and some providers reject a union `type`.
|
|
45
|
+
*
|
|
46
|
+
* EC-556 — the projection is VALIDATOR-facing (the value at rest: asset/relation
|
|
47
|
+
* envelopes, the v2 rich-text object). Every property here is its WRITER form
|
|
48
|
+
* (`writerFieldSchema`), derived from that projection at every depth — a bare asset
|
|
49
|
+
* id, a `{ collection, id }` reference, a markdown string — so an asset, relation or
|
|
50
|
+
* rich-text SUBFIELD of an `object` is told the same form as a top-level one. The
|
|
51
|
+
* top-level relation/asset id enums (EC-381) override the writer form there.
|
|
45
52
|
*/
|
|
46
|
-
import { collectionJsonSchema,
|
|
47
|
-
import {
|
|
48
|
-
import { constraintSentence, stripRedundantPattern, toModelFacingSchema } from './constraints.js';
|
|
49
|
-
/**
|
|
50
|
-
* A rich-text field's model-facing schema (EC-379): a MARKDOWN string, MERGED over
|
|
51
|
-
* the field's own constraint projection (EC-473 — it used to replace it, silently
|
|
52
|
-
* dropping a declared `min`/`max`/`pattern`). The description carries the allowed
|
|
53
|
-
* subset (from the engine converter — one source of truth), so the model has
|
|
54
|
-
* something concrete to aim at; the engine converts the markdown into the
|
|
55
|
-
* `{ version, doc }` storage envelope at proposal-build time and rejects
|
|
56
|
-
* out-of-subset constructs with explicit issues.
|
|
57
|
-
*/
|
|
58
|
-
function richTextMarkdownSchema(field) {
|
|
59
|
-
const constraints = stripRedundantPattern(fieldJsonSchema(field));
|
|
60
|
-
return {
|
|
61
|
-
...constraints,
|
|
62
|
-
type: 'string',
|
|
63
|
-
description: `Rich text, written as ${MARKDOWN_PROSE_DESCRIPTION} Write genuine, publishable prose.`,
|
|
64
|
-
};
|
|
65
|
-
}
|
|
53
|
+
import { collectionJsonSchema, resolveLabel } from '@elytracms/core/cms-core';
|
|
54
|
+
import { constraintSentence, toModelFacingSchema, writerFieldSchema } from './constraints.js';
|
|
66
55
|
/**
|
|
67
56
|
* A field's model-facing description: resolved label + optional `ai.hint` + the
|
|
68
57
|
* constraint clause (EC-473). The constraints are stated in PROSE because that is
|
|
@@ -78,8 +67,9 @@ function describe(field, locale) {
|
|
|
78
67
|
}
|
|
79
68
|
/**
|
|
80
69
|
* Build the leash-aware target shape from a collection + candidates. Deterministic;
|
|
81
|
-
* never throws. Reuses `collectionJsonSchema` as the base
|
|
82
|
-
*
|
|
70
|
+
* never throws. Reuses `collectionJsonSchema` as the base (its `required` list), then
|
|
71
|
+
* gives every property its writer form (EC-556) or its id-enum leash, and strips
|
|
72
|
+
* excluded fields.
|
|
83
73
|
*/
|
|
84
74
|
export function buildTargetShape(input) {
|
|
85
75
|
const base = collectionJsonSchema(input.collection);
|
|
@@ -145,8 +135,10 @@ export function buildTargetShape(input) {
|
|
|
145
135
|
properties[field.name] = field.multiple ? { type: 'array', items: idSchema } : idSchema;
|
|
146
136
|
assetFields.push({ fieldName: field.name, multiple: field.multiple, candidateIds: ids });
|
|
147
137
|
}
|
|
148
|
-
else
|
|
149
|
-
|
|
138
|
+
else {
|
|
139
|
+
// EC-556: the writer form at every depth (rich text → markdown, EC-379 / D2;
|
|
140
|
+
// nested asset / relation → the bare forms the write-time lens wraps).
|
|
141
|
+
properties[field.name] = writerFieldSchema(field);
|
|
150
142
|
}
|
|
151
143
|
const prop = properties[field.name];
|
|
152
144
|
if (prop) {
|
package/dist/lib/ai/shape.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"shape.js","sourceRoot":"","sources":["../../../src/lib/ai/shape.ts"],"names":[],"mappings":"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","sourcesContent":["/**\n * Target-shape derivation for `applyContentWithShape` (EC-331).\n *\n * Reuses the existing collection→JSON-Schema derivation (`collectionJsonSchema`,\n * cms-core) rather than writing a new one, then applies the three leashes the AI\n * engine adds on top:\n *\n * - `ai.exclude` fields are removed entirely (the model never sees them → cannot\n * write them).\n * - relation fields become an ENUM over the provided candidate ids (or are omitted\n * when no candidates exist) — so a hallucinated relation id is not representable\n * as valid structured output.\n * - asset/image fields become an ENUM over the searched library asset ids (or are\n * omitted when none were searched) — EC-381, the SAME leash as relations: a\n * proposed asset id must be a real, `listAssets`-returned library asset, and a\n * create shape with no searched assets simply has no asset property (empty beats\n * wrong; the assistant never generates or uploads).\n * - rich-text fields are MARKDOWN STRINGS (EC-379): the description names the\n * allowed subset, and the engine converts md → the `{ version, doc }` storage\n * envelope at proposal-build time. The field's `allow` config leashes component\n * EMBEDS only — markdown cannot express an embed, so the leash is satisfied by\n * construction (and still enforced downstream by `validateRichTextEmbeds`).\n *\n * Localized fields stay flat in the shape (the model authors one locale at a time);\n * the engine routes their values under `localized[locale]`.\n *\n * EC-473 — the shape now consumes the ONE constraint projection, so a rule declared\n * in config actually reaches the model:\n *\n * - `collectionJsonSchema` merges declarative rules with the fragment derived from\n * a schema-form `validation.validate`, so `{ min: 2 }` and `z.string().min(2)`\n * arrive identically (and a declared DATE RANGE arrives at all — it previously\n * reached no consumer whatsoever);\n * - a rich-text field no longer OVERWRITES its constraints: the markdown schema is\n * merged over them instead of replacing them (measured drift, EC-474 §2);\n * - a `pattern` is dropped wherever a `format` explains the same thing (a\n * 200-character email regex teaches a model nothing and costs real tokens);\n * - a field whose projection is knowingly partial (`schemaComplete: false`) SAYS SO\n * in its description rather than presenting the partial rule as the whole rule.\n *\n * EC-505 — one further narrowing of what the model is TOLD (never of what the engine\n * accepts): a `date` property is `type: 'string'` + `format: 'date-time'` rather than\n * the `['string','number']` union the general projection carries, because the model is\n * asked to author an ISO instant (EC-472) and some providers reject a union `type`.\n */\nimport { collectionJsonSchema, fieldJsonSchema, resolveLabel } from '@elytracms/core/cms-core'\nimport type { CollectionDef, FieldDef, JsonSchema } from '@elytracms/core/cms-core'\nimport { MARKDOWN_PROSE_DESCRIPTION } from '@elytracms/core/rich-text'\nimport { constraintSentence, stripRedundantPattern, toModelFacingSchema } from './constraints'\nimport type { AssetCandidates, RelationCandidates } from './types'\n\n/** A relation field's resolved candidate set — how the engine maps ids → reference envelopes. */\nexport interface TargetRelationField {\n fieldName: string\n /** The relation's target collection id. */\n target: string\n multiple: boolean\n /** The allowed candidate ids (the enum the model chose from). */\n candidateIds: string[]\n}\n\n/** An asset field's resolved candidate set — how the engine maps ids → asset envelopes (EC-381). */\nexport interface TargetAssetField {\n fieldName: string\n multiple: boolean\n /** The allowed library asset ids (the enum the model chose from). */\n candidateIds: string[]\n}\n\n/** The derived target shape + the metadata the engine needs to post-process the output. */\nexport interface TargetShape {\n /** The JSON Schema the structured output must satisfy. */\n schema: JsonSchema\n /** Field names removed by `ai.exclude` (untouchable). */\n excluded: string[]\n /** Field names whose values route under `localized[locale]`. */\n localizedFields: string[]\n /** Relation fields with their candidate enums. */\n relationFields: TargetRelationField[]\n /** Asset fields with their candidate enums (EC-381). */\n assetFields: TargetAssetField[]\n}\n\nexport interface BuildTargetShapeInput {\n collection: CollectionDef\n /** Relation candidate ids by field — a relation field with none is omitted from the shape. */\n candidates?: RelationCandidates\n /** Asset candidate ids by field — an asset field with none is omitted from the shape (EC-381). */\n assetCandidates?: AssetCandidates\n /** UI locale for resolving field labels into descriptions. */\n locale?: string\n}\n\n/**\n * A rich-text field's model-facing schema (EC-379): a MARKDOWN string, MERGED over\n * the field's own constraint projection (EC-473 — it used to replace it, silently\n * dropping a declared `min`/`max`/`pattern`). The description carries the allowed\n * subset (from the engine converter — one source of truth), so the model has\n * something concrete to aim at; the engine converts the markdown into the\n * `{ version, doc }` storage envelope at proposal-build time and rejects\n * out-of-subset constructs with explicit issues.\n */\nfunction richTextMarkdownSchema(field: FieldDef): JsonSchema {\n const constraints = stripRedundantPattern(fieldJsonSchema(field))\n return {\n ...constraints,\n type: 'string',\n description: `Rich text, written as ${MARKDOWN_PROSE_DESCRIPTION} Write genuine, publishable prose.`,\n }\n}\n\n/**\n * A field's model-facing description: resolved label + optional `ai.hint` + the\n * constraint clause (EC-473). The constraints are stated in PROSE because that is\n * what a model acts on — the same facts also ride as JSON-Schema keywords for the\n * structured-output validator, which is a different reader.\n */\nfunction describe(field: FieldDef, locale?: string): string | undefined {\n const label = resolveLabel(field.form?.label, locale) ?? field.name\n const hint = field.ai?.hint\n const constraints = constraintSentence(field)\n const parts = [label, hint, constraints].filter(\n (p): p is string => typeof p === 'string' && p.length > 0,\n )\n return parts.length > 0 ? parts.join(' — ') : undefined\n}\n\n/**\n * Build the leash-aware target shape from a collection + candidates. Deterministic;\n * never throws. Reuses `collectionJsonSchema` as the base, then rewrites relations /\n * rich text and strips excluded fields.\n */\nexport function buildTargetShape(input: BuildTargetShapeInput): TargetShape {\n const base = collectionJsonSchema(input.collection)\n const properties: Record<string, JsonSchema> = {\n ...((base.properties as Record<string, JsonSchema>) ?? {}),\n }\n const excluded: string[] = []\n const localizedFields: string[] = []\n const relationFields: TargetRelationField[] = []\n const assetFields: TargetAssetField[] = []\n\n for (const field of input.collection.fields) {\n if (field.localized) localizedFields.push(field.name)\n\n if (field.ai?.exclude) {\n excluded.push(field.name)\n delete properties[field.name]\n continue\n }\n\n if (field.type === 'relation') {\n const candidates = input.candidates?.[field.name] ?? []\n if (candidates.length === 0) {\n // No candidate ids ⇒ omit the field: the model cannot emit a relation value\n // at all, so a hallucinated id is structurally impossible.\n delete properties[field.name]\n continue\n }\n const ids = candidates.map((c) => c.id)\n const label = candidates\n .map((c) => (c.label ? `${c.id} (${c.label})` : c.id))\n .join(', ')\n const idSchema: JsonSchema = {\n type: 'string',\n enum: ids,\n description: `Choose one of: ${label}`,\n }\n properties[field.name] = field.multiple ? { type: 'array', items: idSchema } : idSchema\n relationFields.push({\n fieldName: field.name,\n target: field.target,\n multiple: field.multiple,\n candidateIds: ids,\n })\n } else if (field.type === 'asset') {\n // EC-381 — same leash as relations: an asset field is an enum over the ids\n // the model actually searched in the media library (`listAssets`). No\n // searched assets ⇒ omit the field, so the proposal simply leaves it empty\n // (an invented / to-be-uploaded id is structurally unrepresentable).\n const candidates = input.assetCandidates?.[field.name] ?? []\n if (candidates.length === 0) {\n delete properties[field.name]\n continue\n }\n const ids = candidates.map((c) => c.id)\n const label = candidates\n .map((c) => (c.label ? `${c.id} (${c.label})` : c.id))\n .join(', ')\n const idSchema: JsonSchema = {\n type: 'string',\n enum: ids,\n description: `Choose an EXISTING library asset id, one of: ${label}. Leave empty if none fits.`,\n }\n properties[field.name] = field.multiple ? { type: 'array', items: idSchema } : idSchema\n assetFields.push({ fieldName: field.name, multiple: field.multiple, candidateIds: ids })\n } else if (field.type === 'richText') {\n properties[field.name] = richTextMarkdownSchema(field)\n }\n\n const prop = properties[field.name]\n if (prop) {\n // EC-473: a `pattern` next to a `format` is 200+ characters that teach the\n // model nothing — the format (and the prose constraints below) say it better.\n // EC-505: and a `date`'s `['string','number']` union becomes the ISO string\n // the model is actually asked to author.\n const stripped = toModelFacingSchema(prop)\n const desc = describe(field, input.locale)\n // Preserve an existing description (e.g. relation enum / rich-text note), but\n // append the constraint clause to it — the enum leash and the value rules are\n // different facts and the model needs both.\n const next: JsonSchema = stripped === prop ? prop : { ...stripped }\n if (desc !== undefined) {\n const existing = typeof next.description === 'string' ? next.description : undefined\n const constraints = constraintSentence(field)\n next.description =\n existing === undefined\n ? desc\n : constraints !== undefined\n ? `${existing} — ${constraints}`\n : existing\n }\n properties[field.name] = next\n }\n }\n\n const requiredIn = Array.isArray(base.required) ? (base.required as string[]) : []\n const required = requiredIn.filter((n) => !excluded.includes(n) && properties[n] !== undefined)\n\n const schema: JsonSchema = {\n type: 'object',\n properties,\n additionalProperties: false,\n ...(required.length > 0 ? { required } : {}),\n }\n return { schema, excluded, localizedFields, relationFields, assetFields }\n}\n"]}
|
|
1
|
+
{"version":3,"file":"shape.js","sourceRoot":"","sources":["../../../src/lib/ai/shape.ts"],"names":[],"mappings":"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","sourcesContent":["/**\n * Target-shape derivation for `applyContentWithShape` (EC-331).\n *\n * Reuses the existing collection→JSON-Schema derivation (`collectionJsonSchema`,\n * cms-core) rather than writing a new one, then applies the three leashes the AI\n * engine adds on top:\n *\n * - `ai.exclude` fields are removed entirely (the model never sees them → cannot\n * write them).\n * - relation fields become an ENUM over the provided candidate ids (or are omitted\n * when no candidates exist) — so a hallucinated relation id is not representable\n * as valid structured output.\n * - asset/image fields become an ENUM over the searched library asset ids (or are\n * omitted when none were searched) — EC-381, the SAME leash as relations: a\n * proposed asset id must be a real, `listAssets`-returned library asset, and a\n * create shape with no searched assets simply has no asset property (empty beats\n * wrong; the assistant never generates or uploads).\n * - rich-text fields are MARKDOWN STRINGS (EC-379): the description names the\n * allowed subset, and the engine converts md → the `{ version, doc }` storage\n * envelope at proposal-build time. The field's `allow` config leashes component\n * EMBEDS only — markdown cannot express an embed, so the leash is satisfied by\n * construction (and still enforced downstream by `validateRichTextEmbeds`).\n *\n * Localized fields stay flat in the shape (the model authors one locale at a time);\n * the engine routes their values under `localized[locale]`.\n *\n * EC-473 — the shape now consumes the ONE constraint projection, so a rule declared\n * in config actually reaches the model:\n *\n * - `collectionJsonSchema` merges declarative rules with the fragment derived from\n * a schema-form `validation.validate`, so `{ min: 2 }` and `z.string().min(2)`\n * arrive identically (and a declared DATE RANGE arrives at all — it previously\n * reached no consumer whatsoever);\n * - a rich-text field no longer OVERWRITES its constraints: the markdown schema is\n * merged over them instead of replacing them (measured drift, EC-474 §2);\n * - a `pattern` is dropped wherever a `format` explains the same thing (a\n * 200-character email regex teaches a model nothing and costs real tokens);\n * - a field whose projection is knowingly partial (`schemaComplete: false`) SAYS SO\n * in its description rather than presenting the partial rule as the whole rule.\n *\n * EC-505 — one further narrowing of what the model is TOLD (never of what the engine\n * accepts): a `date` property is `type: 'string'` + `format: 'date-time'` rather than\n * the `['string','number']` union the general projection carries, because the model is\n * asked to author an ISO instant (EC-472) and some providers reject a union `type`.\n *\n * EC-556 — the projection is VALIDATOR-facing (the value at rest: asset/relation\n * envelopes, the v2 rich-text object). Every property here is its WRITER form\n * (`writerFieldSchema`), derived from that projection at every depth — a bare asset\n * id, a `{ collection, id }` reference, a markdown string — so an asset, relation or\n * rich-text SUBFIELD of an `object` is told the same form as a top-level one. The\n * top-level relation/asset id enums (EC-381) override the writer form there.\n */\nimport { collectionJsonSchema, resolveLabel } from '@elytracms/core/cms-core'\nimport type { CollectionDef, FieldDef, JsonSchema } from '@elytracms/core/cms-core'\nimport { constraintSentence, toModelFacingSchema, writerFieldSchema } from './constraints'\nimport type { AssetCandidates, RelationCandidates } from './types'\n\n/** A relation field's resolved candidate set — how the engine maps ids → reference envelopes. */\nexport interface TargetRelationField {\n fieldName: string\n /** The relation's target collection id. */\n target: string\n multiple: boolean\n /** The allowed candidate ids (the enum the model chose from). */\n candidateIds: string[]\n}\n\n/** An asset field's resolved candidate set — how the engine maps ids → asset envelopes (EC-381). */\nexport interface TargetAssetField {\n fieldName: string\n multiple: boolean\n /** The allowed library asset ids (the enum the model chose from). */\n candidateIds: string[]\n}\n\n/** The derived target shape + the metadata the engine needs to post-process the output. */\nexport interface TargetShape {\n /** The JSON Schema the structured output must satisfy. */\n schema: JsonSchema\n /** Field names removed by `ai.exclude` (untouchable). */\n excluded: string[]\n /** Field names whose values route under `localized[locale]`. */\n localizedFields: string[]\n /** Relation fields with their candidate enums. */\n relationFields: TargetRelationField[]\n /** Asset fields with their candidate enums (EC-381). */\n assetFields: TargetAssetField[]\n}\n\nexport interface BuildTargetShapeInput {\n collection: CollectionDef\n /** Relation candidate ids by field — a relation field with none is omitted from the shape. */\n candidates?: RelationCandidates\n /** Asset candidate ids by field — an asset field with none is omitted from the shape (EC-381). */\n assetCandidates?: AssetCandidates\n /** UI locale for resolving field labels into descriptions. */\n locale?: string\n}\n\n/**\n * A field's model-facing description: resolved label + optional `ai.hint` + the\n * constraint clause (EC-473). The constraints are stated in PROSE because that is\n * what a model acts on — the same facts also ride as JSON-Schema keywords for the\n * structured-output validator, which is a different reader.\n */\nfunction describe(field: FieldDef, locale?: string): string | undefined {\n const label = resolveLabel(field.form?.label, locale) ?? field.name\n const hint = field.ai?.hint\n const constraints = constraintSentence(field)\n const parts = [label, hint, constraints].filter(\n (p): p is string => typeof p === 'string' && p.length > 0,\n )\n return parts.length > 0 ? parts.join(' — ') : undefined\n}\n\n/**\n * Build the leash-aware target shape from a collection + candidates. Deterministic;\n * never throws. Reuses `collectionJsonSchema` as the base (its `required` list), then\n * gives every property its writer form (EC-556) or its id-enum leash, and strips\n * excluded fields.\n */\nexport function buildTargetShape(input: BuildTargetShapeInput): TargetShape {\n const base = collectionJsonSchema(input.collection)\n const properties: Record<string, JsonSchema> = {\n ...((base.properties as Record<string, JsonSchema>) ?? {}),\n }\n const excluded: string[] = []\n const localizedFields: string[] = []\n const relationFields: TargetRelationField[] = []\n const assetFields: TargetAssetField[] = []\n\n for (const field of input.collection.fields) {\n if (field.localized) localizedFields.push(field.name)\n\n if (field.ai?.exclude) {\n excluded.push(field.name)\n delete properties[field.name]\n continue\n }\n\n if (field.type === 'relation') {\n const candidates = input.candidates?.[field.name] ?? []\n if (candidates.length === 0) {\n // No candidate ids ⇒ omit the field: the model cannot emit a relation value\n // at all, so a hallucinated id is structurally impossible.\n delete properties[field.name]\n continue\n }\n const ids = candidates.map((c) => c.id)\n const label = candidates\n .map((c) => (c.label ? `${c.id} (${c.label})` : c.id))\n .join(', ')\n const idSchema: JsonSchema = {\n type: 'string',\n enum: ids,\n description: `Choose one of: ${label}`,\n }\n properties[field.name] = field.multiple ? { type: 'array', items: idSchema } : idSchema\n relationFields.push({\n fieldName: field.name,\n target: field.target,\n multiple: field.multiple,\n candidateIds: ids,\n })\n } else if (field.type === 'asset') {\n // EC-381 — same leash as relations: an asset field is an enum over the ids\n // the model actually searched in the media library (`listAssets`). No\n // searched assets ⇒ omit the field, so the proposal simply leaves it empty\n // (an invented / to-be-uploaded id is structurally unrepresentable).\n const candidates = input.assetCandidates?.[field.name] ?? []\n if (candidates.length === 0) {\n delete properties[field.name]\n continue\n }\n const ids = candidates.map((c) => c.id)\n const label = candidates\n .map((c) => (c.label ? `${c.id} (${c.label})` : c.id))\n .join(', ')\n const idSchema: JsonSchema = {\n type: 'string',\n enum: ids,\n description: `Choose an EXISTING library asset id, one of: ${label}. Leave empty if none fits.`,\n }\n properties[field.name] = field.multiple ? { type: 'array', items: idSchema } : idSchema\n assetFields.push({ fieldName: field.name, multiple: field.multiple, candidateIds: ids })\n } else {\n // EC-556: the writer form at every depth (rich text → markdown, EC-379 / D2;\n // nested asset / relation → the bare forms the write-time lens wraps).\n properties[field.name] = writerFieldSchema(field)\n }\n\n const prop = properties[field.name]\n if (prop) {\n // EC-473: a `pattern` next to a `format` is 200+ characters that teach the\n // model nothing — the format (and the prose constraints below) say it better.\n // EC-505: and a `date`'s `['string','number']` union becomes the ISO string\n // the model is actually asked to author.\n const stripped = toModelFacingSchema(prop)\n const desc = describe(field, input.locale)\n // Preserve an existing description (e.g. relation enum / rich-text note), but\n // append the constraint clause to it — the enum leash and the value rules are\n // different facts and the model needs both.\n const next: JsonSchema = stripped === prop ? prop : { ...stripped }\n if (desc !== undefined) {\n const existing = typeof next.description === 'string' ? next.description : undefined\n const constraints = constraintSentence(field)\n next.description =\n existing === undefined\n ? desc\n : constraints !== undefined\n ? `${existing} — ${constraints}`\n : existing\n }\n properties[field.name] = next\n }\n }\n\n const requiredIn = Array.isArray(base.required) ? (base.required as string[]) : []\n const required = requiredIn.filter((n) => !excluded.includes(n) && properties[n] !== undefined)\n\n const schema: JsonSchema = {\n type: 'object',\n properties,\n additionalProperties: false,\n ...(required.length > 0 ? { required } : {}),\n }\n return { schema, excluded, localizedFields, relationFields, assetFields }\n}\n"]}
|
package/dist/lib/ai/types.d.ts
CHANGED
|
@@ -242,6 +242,14 @@ export interface AiRouteHandlerOptions {
|
|
|
242
242
|
limits?: AiLimits;
|
|
243
243
|
/** Derive the per-project rate-bucket key from a request (default: one global bucket). */
|
|
244
244
|
projectKey?: (request: Request) => string;
|
|
245
|
+
/**
|
|
246
|
+
* Millisecond clock the rate window is decided against (default `Date.now`).
|
|
247
|
+
* Injected so the 60 s boundary is testable with a fixed clock (EC-602, the
|
|
248
|
+
* AGENTS.md §4 determinism rule). Milliseconds rather than the house ISO
|
|
249
|
+
* `Clock` (`@elytracms/core/determinism`): that one stamps records; this one
|
|
250
|
+
* does window arithmetic.
|
|
251
|
+
*/
|
|
252
|
+
now?: () => number;
|
|
245
253
|
}
|
|
246
254
|
/** The mounted route handler: a framework-agnostic Web `Request → Response`. */
|
|
247
255
|
export type AiRouteHandler = (request: Request) => Promise<Response>;
|