@aglyn/plugins-ai 1.0.0-beta.249 → 1.0.0-beta.252

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.
Files changed (110) hide show
  1. package/package.json +17 -17
  2. package/src/lib/components/ai-brief-dialog.component.js +15 -1
  3. package/src/lib/components/ai-brief-dialog.component.js.map +1 -1
  4. package/src/lib/components/ai-job-links.js +1 -1
  5. package/src/lib/components/ai-job-links.js.map +1 -1
  6. package/src/lib/components/ai-job-plan.component.js +20 -2
  7. package/src/lib/components/ai-job-plan.component.js.map +1 -1
  8. package/src/lib/components/ai-jobs-store.d.ts +11 -0
  9. package/src/lib/components/ai-jobs-store.js +22 -0
  10. package/src/lib/components/ai-jobs-store.js.map +1 -1
  11. package/src/lib/jobs/ai-job-build-step.js +3 -2
  12. package/src/lib/jobs/ai-job-build-step.js.map +1 -1
  13. package/src/lib/jobs/ai-job-crm-step.js +7 -4
  14. package/src/lib/jobs/ai-job-crm-step.js.map +1 -1
  15. package/src/lib/jobs/ai-job-drafts.d.ts +24 -0
  16. package/src/lib/jobs/ai-job-drafts.js +46 -0
  17. package/src/lib/jobs/ai-job-drafts.js.map +1 -1
  18. package/src/lib/jobs/ai-job-experiment-step.js +6 -1
  19. package/src/lib/jobs/ai-job-experiment-step.js.map +1 -1
  20. package/src/lib/jobs/ai-job-form-reuse.d.ts +78 -0
  21. package/src/lib/jobs/ai-job-form-reuse.js +136 -0
  22. package/src/lib/jobs/ai-job-form-reuse.js.map +1 -0
  23. package/src/lib/jobs/ai-job-form-step.d.ts +17 -0
  24. package/src/lib/jobs/ai-job-form-step.js +63 -11
  25. package/src/lib/jobs/ai-job-form-step.js.map +1 -1
  26. package/src/lib/jobs/ai-job-insight-step.js +6 -1
  27. package/src/lib/jobs/ai-job-insight-step.js.map +1 -1
  28. package/src/lib/jobs/ai-job-page-language.d.ts +12 -1
  29. package/src/lib/jobs/ai-job-page-language.js +48 -11
  30. package/src/lib/jobs/ai-job-page-language.js.map +1 -1
  31. package/src/lib/jobs/ai-job-page-step.js +8 -3
  32. package/src/lib/jobs/ai-job-page-step.js.map +1 -1
  33. package/src/lib/jobs/ai-job-plan-step.d.ts +19 -0
  34. package/src/lib/jobs/ai-job-plan-step.js +39 -1
  35. package/src/lib/jobs/ai-job-plan-step.js.map +1 -1
  36. package/src/lib/jobs/ai-job-seo-step.js +5 -3
  37. package/src/lib/jobs/ai-job-seo-step.js.map +1 -1
  38. package/src/lib/jobs/ai-job-site-content.d.ts +7 -5
  39. package/src/lib/jobs/ai-job-site-content.js +7 -5
  40. package/src/lib/jobs/ai-job-site-content.js.map +1 -1
  41. package/src/lib/jobs/ai-job-site-datasets.d.ts +17 -0
  42. package/src/lib/jobs/ai-job-site-datasets.js +24 -0
  43. package/src/lib/jobs/ai-job-site-datasets.js.map +1 -1
  44. package/src/lib/jobs/ai-job-site-step.d.ts +5 -2
  45. package/src/lib/jobs/ai-job-site-step.js +20 -6
  46. package/src/lib/jobs/ai-job-site-step.js.map +1 -1
  47. package/src/lib/jobs/ai-job-text-step.js +6 -3
  48. package/src/lib/jobs/ai-job-text-step.js.map +1 -1
  49. package/src/lib/jobs/ai-layout-stock-photos.d.ts +22 -8
  50. package/src/lib/jobs/ai-layout-stock-photos.js +39 -8
  51. package/src/lib/jobs/ai-layout-stock-photos.js.map +1 -1
  52. package/src/lib/jobs/ai-stock-business-nouns.d.ts +62 -0
  53. package/src/lib/jobs/ai-stock-business-nouns.js +264 -0
  54. package/src/lib/jobs/ai-stock-business-nouns.js.map +1 -0
  55. package/src/lib/layout-language/ai-layout-gaps.d.ts +15 -0
  56. package/src/lib/layout-language/ai-layout-gaps.js +57 -7
  57. package/src/lib/layout-language/ai-layout-gaps.js.map +1 -1
  58. package/src/lib/model/ai-build-plan.d.ts +7 -3
  59. package/src/lib/model/ai-build-plan.js +11 -1
  60. package/src/lib/model/ai-build-plan.js.map +1 -1
  61. package/src/lib/model/ai-credit-estimate.d.ts +32 -18
  62. package/src/lib/model/ai-credit-estimate.js +36 -18
  63. package/src/lib/model/ai-credit-estimate.js.map +1 -1
  64. package/src/lib/model/ai-job-activity.d.ts +30 -0
  65. package/src/lib/model/ai-job-activity.js +35 -0
  66. package/src/lib/model/ai-job-activity.js.map +1 -1
  67. package/src/lib/model/ai-media-credits.d.ts +5 -3
  68. package/src/lib/model/ai-media-credits.js +7 -5
  69. package/src/lib/model/ai-media-credits.js.map +1 -1
  70. package/src/lib/model/ai-site-job.js +6 -3
  71. package/src/lib/model/ai-site-job.js.map +1 -1
  72. package/src/lib/model/ai-site-store-pages.d.ts +5 -0
  73. package/src/lib/model/ai-site-store-pages.js +2 -0
  74. package/src/lib/model/ai-site-store-pages.js.map +1 -1
  75. package/src/lib/plugin-config.js +2 -2
  76. package/src/lib/plugin-config.js.map +1 -1
  77. package/src/lib/providers/anthropic-crm-wording-superseded.d.ts +29 -0
  78. package/src/lib/providers/anthropic-crm-wording-superseded.js +31 -0
  79. package/src/lib/providers/anthropic-crm-wording-superseded.js.map +1 -0
  80. package/src/lib/providers/catalog.d.ts +24 -17
  81. package/src/lib/providers/catalog.js +63 -30
  82. package/src/lib/providers/catalog.js.map +1 -1
  83. package/src/lib/providers/model-choice.d.ts +1 -1
  84. package/src/lib/providers/model-choice.js +39 -30
  85. package/src/lib/providers/model-choice.js.map +1 -1
  86. package/src/lib/providers/routing.d.ts +20 -1
  87. package/src/lib/providers/routing.js +47 -8
  88. package/src/lib/providers/routing.js.map +1 -1
  89. package/src/lib/runtime/ai-dev-env.d.ts +37 -1
  90. package/src/lib/runtime/ai-dev-env.js +59 -6
  91. package/src/lib/runtime/ai-dev-env.js.map +1 -1
  92. package/src/lib/runtime/ai-doctrine-validators.js +3 -1
  93. package/src/lib/runtime/ai-doctrine-validators.js.map +1 -1
  94. package/src/lib/runtime/ai-doctrine.js +8 -4
  95. package/src/lib/runtime/ai-doctrine.js.map +1 -1
  96. package/src/lib/runtime/ai-eval-live.d.ts +8 -0
  97. package/src/lib/runtime/ai-eval-live.js +34 -8
  98. package/src/lib/runtime/ai-eval-live.js.map +1 -1
  99. package/src/lib/runtime/ai-palette.generated.js +4 -0
  100. package/src/lib/runtime/ai-palette.generated.js.map +1 -1
  101. package/src/lib/runtime/ai-runtime.js +17 -2
  102. package/src/lib/runtime/ai-runtime.js.map +1 -1
  103. package/src/lib/runtime/seo-fields.js +4 -3
  104. package/src/lib/runtime/seo-fields.js.map +1 -1
  105. package/src/lib/server/ai-assist.js +3 -2
  106. package/src/lib/server/ai-assist.js.map +1 -1
  107. package/src/lib/server/ai-media-svg.js +4 -0
  108. package/src/lib/server/ai-media-svg.js.map +1 -1
  109. package/src/lib/usage/ai-allotment-alerts.js +2 -5
  110. package/src/lib/usage/ai-allotment-alerts.js.map +1 -1
@@ -15,10 +15,10 @@ import { _ as _object_without_properties_loose } from "@swc/helpers/_/_object_wi
15
15
  * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
16
16
  * See the License for the specific language governing permissions and
17
17
  * limitations under the License.
18
- */ import { aiModelCacheMinTokens } from "../providers/catalog.js";
18
+ */ import { aiCatalogEntry, aiModelCacheMinTokens } from "../providers/catalog.js";
19
19
  import { AI_IMAGE_MAX_BYTES, AI_IMAGE_MEDIA_TYPES, AI_REQUEST_MAX_IMAGES, AiRequestShapeError, AiUpstreamError, aiBase64Bytes, aiMessageImages } from "../providers/contract.js";
20
20
  import { resolveAiProvider } from "../providers/routing.js";
21
- import { aiDevCacheTtl, aiDevReplayMode, aiIsDeployedRuntime, aiLiveBatchWanted, aiWarnIfReplayAskedInDeployment } from "./ai-dev-env.js";
21
+ import { aiDevCacheTtl, aiDevReplayMode, aiIsDeployedRuntime, aiLiveBatchWanted, aiUnderLiveRun, aiWarnIfReplayAskedInDeployment } from "./ai-dev-env.js";
22
22
  import { aiCompleteInBatch } from "./ai-live-batch.js";
23
23
  export { AI_UPSTREAM_FAILURE_COPY, AiRequestShapeError, AiUpstreamError } from "../providers/contract.js";
24
24
  /**
@@ -231,6 +231,7 @@ function providerFor(input) {
231
231
  export async function runAiRequest(input) {
232
232
  var _request_cacheTtl;
233
233
  validateAiSystemBlocks(input.system);
234
+ refuseRetiredModelInLiveRun(input.model);
234
235
  const { provider, apiKey } = providerFor(input);
235
236
  validateAiMessages(input.messages, ()=>aiModelReadsImages(provider, input.model));
236
237
  const { stream, settings: _settings, provider: _provider } = input, request = _object_without_properties_loose(input, [
@@ -255,6 +256,20 @@ export async function runAiRequest(input) {
255
256
  throw error;
256
257
  }
257
258
  }
259
+ /**
260
+ * A live development run that names a retired model is refused before it
261
+ * spends (AGL-3781). Routing serves a retired id's successor wherever a
262
+ * setting or an override names it, so a retired id reaching here was asked
263
+ * for by name — an override left in a run, or a comparison a spec kept — and
264
+ * would bill a model production no longer serves.
265
+ */ function refuseRetiredModelInLiveRun(model) {
266
+ var _aiCatalogEntry;
267
+ if (!aiUnderLiveRun()) return;
268
+ const successor = (_aiCatalogEntry = aiCatalogEntry(model)) == null ? void 0 : _aiCatalogEntry.successor;
269
+ if (successor) {
270
+ throw new AiRequestShapeError(`${model} is retired; ${successor} serves in its place`);
271
+ }
272
+ }
258
273
  /**
259
274
  * A request in a development process (AGL-3660): through the replay cache,
260
275
  * batch mode and the first-request-warms-the-prefix rule when this process
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../../../../../libs/plugins/ai/src/lib/runtime/ai-runtime.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\nimport { aiModelCacheMinTokens } from '../providers/catalog'\nimport {\n AI_IMAGE_MAX_BYTES,\n AI_IMAGE_MEDIA_TYPES,\n AI_REQUEST_MAX_IMAGES,\n AiRequestShapeError,\n AiUpstreamError,\n aiBase64Bytes,\n aiMessageImages,\n type AiMessage,\n type AiProvider,\n type AiProviderRequest,\n type AiRequestBase,\n type AiResult,\n type AiStreamEvent,\n type AiSystemBlock,\n type AiTool,\n} from '../providers/contract'\nimport { resolveAiProvider, type AiPluginSettings } from '../providers/routing'\nimport {\n aiDevCacheTtl,\n aiDevReplayMode,\n aiIsDeployedRuntime,\n aiLiveBatchWanted,\n aiWarnIfReplayAskedInDeployment,\n} from './ai-dev-env'\nimport { aiCompleteInBatch } from './ai-live-batch'\n\nexport {\n AI_UPSTREAM_FAILURE_COPY,\n AiRequestShapeError,\n AiUpstreamError,\n type AiCompletion,\n type AiEffort,\n type AiImagePart,\n type AiMessage,\n type AiMessagePart,\n type AiRefusal,\n type AiRequestBase,\n type AiResult,\n type AiStreamEvent,\n type AiSystemBlock,\n type AiThinking,\n type AiTool,\n type AiToolUse,\n type AiUsage,\n} from '../providers/contract'\n\n/**\n * The one runtime every AI door calls (AGL-2903), provider-generic since\n * AGL-2939.\n *\n * `/api/assist/chat` and `/api/ai/assist` each carried their own `fetch` to\n * one vendor, their own SSE parser and their own usage extraction, and a\n * third door would have been a third copy. This module owns the request\n * shape, the cache-prefix rule, the provider resolution and the error\n * boundary; an adapter owns one vendor's wire protocol; a door owns its\n * prompt, its gate ladder and its wire format.\n *\n * The cache-prefix rule (AGL-2352) is enforced HERE, before any adapter:\n * a block marked `volatile` may not sit at or before the last\n * `cacheBreakpoint`, and more breakpoints than any provider caches are\n * refused. A provider without a cache ignores the markers and keeps the\n * order, so a request that is right for the caching adapter is right for\n * every adapter.\n *\n * The picture rule (AGL-2916) is enforced here too, and for the same reason:\n * a picture on an assistant turn, a picture for a model whose provider does\n * not say it reads one, or a picture past the bounds is refused before any\n * adapter is reached, so no door can send one a provider would reject.\n */\n\n/** The most `cacheBreakpoint` markers a request may carry. */\nexport const AI_MAX_CACHE_BREAKPOINTS = 4\n\n/**\n * The acceptable-use rules every generation prompt carries (AGL-2925).\n *\n * ONE constant, appended to each door's STATIC system text — inside the\n * cached prefix on the console chat, ahead of the mode prompt on the copy\n * assistant — so the rules the model is held to are the same at every\n * door and carry no per-org byte. The Free taste is what makes them worth\n * stating: a generated page that nobody paid for is the cheapest phishing\n * kit there is, and the model declining it is the first line, ahead of the\n * abuse-report route and the lockdown key.\n *\n * The categories are the platform's own Acceptable Use Policy, restated in\n * the second person for the model. A decline from the model's safety layer\n * arrives as a refusal, which the meter counts against the account's daily\n * pause and never against its credits.\n */\nexport const AI_ACCEPTABLE_USE_BLOCK = `Acceptable use — decline these rather than write them, and say briefly why:\n- Pages, emails or copy that harvest credentials, payment details or personal data by deception (phishing, fake sign-in or checkout pages, fake \"verify your account\" notices).\n- Impersonating a real business, brand, public body or person you were not told the user represents — their name, logo, sign-in page or voice — or copy that claims an endorsement, certification or partnership that was not given to you as a fact.\n- Content that promotes or sells prohibited goods and services: weapons, illegal drugs, counterfeit goods, malware or deceptive downloads, unlicensed gambling, or adult content involving minors.\n- Scams and fraud: fake investment or crypto schemes, advance-fee offers, tech-support scams, fake charities, deceptive \"you have won\" pages, or copy built to defraud.\n- Harassment, threats, or hateful content targeting a person or a group.\n- Spam farms: bulk near-duplicate pages or copy whose only purpose is to game search engines or send unsolicited mail.\nWhen a brief is ordinary business content, write it well and do not lecture.`\n\n/** A door's request, with the org's settings when the door knows the org. */\ninterface AiRuntimeOptions {\n /** The org's resolved `pluginSettings/ai`, for its provider and model choices. */\n settings?: AiPluginSettings\n /** A provider chosen by the caller, ahead of every setting; specs use it. */\n provider?: AiProvider\n}\n\nexport interface AiRequest extends AiRequestBase, AiRuntimeOptions {\n stream: false\n}\n\nexport interface AiStreamRequest extends AiRequestBase, AiRuntimeOptions {\n stream: true\n}\n\n/**\n * The cache guard, applied to the system blocks before any adapter sees\n * them. Exported so a spec and a door can assert a prompt's shape without\n * a provider.\n */\nexport function validateAiSystemBlocks(system: readonly AiSystemBlock[]): void {\n const breakpoints = system.filter((block) => block.cacheBreakpoint)\n if (breakpoints.length > AI_MAX_CACHE_BREAKPOINTS) {\n throw new AiRequestShapeError(\n `${breakpoints.length} cache breakpoints; at most ${AI_MAX_CACHE_BREAKPOINTS} are cached`,\n )\n }\n // Caching is a prefix match, so the LAST breakpoint decides what is\n // cached, and every block at or before it is inside the cached prefix. A\n // volatile block anywhere in that span — including one that is itself\n // the breakpoint — puts a per-request byte into the prefix (AGL-2352).\n const lastBreakpoint = system.reduce(\n (last, block, index) => (block.cacheBreakpoint ? index : last),\n -1,\n )\n system.forEach((block, index) => {\n if (block.volatile && index <= lastBreakpoint) {\n throw new AiRequestShapeError(\n `system block ${index} is volatile but sits inside the cached prefix (last breakpoint at block ${lastBreakpoint})`,\n )\n }\n })\n // A site's block is cached per site (AGL-3661), so it closes the cached\n // span: a platform-wide breakpoint after it would be keyed on the site too,\n // and every workspace would warm its own copy of a shared prompt.\n const firstSite = system.findIndex((block) => block.site && block.cacheBreakpoint)\n if (firstSite >= 0 && firstSite !== lastBreakpoint) {\n throw new AiRequestShapeError(\n `system block ${firstSite} is a site's cached block but breakpoint ${lastBreakpoint} follows it`,\n )\n }\n}\n\n/**\n * Whether a provider's model reads a picture in a user turn (AGL-2916): what\n * the provider's own descriptor of the model says. A model the provider does\n * not describe reads none, so an adopter's provider is never sent a picture\n * it did not say it can take.\n */\nexport function aiModelReadsImages(provider: AiProvider, model: string): boolean {\n return provider\n .models()\n .some((descriptor) => descriptor.id === model && descriptor.capabilities.vision === true)\n}\n\n/**\n * Whether a model a door resolved reads pictures on the provider that would\n * serve the request: what a door asks before it attaches one, so a model that\n * does not is sent the text alone rather than refused.\n */\nexport function aiRouteReadsImages(model: string, settings?: AiPluginSettings): boolean {\n const provider = resolveAiProvider(settings)\n return provider ? aiModelReadsImages(provider, model) : false\n}\n\n/** A character standard base64 does not use, outside its padding. */\nconst NOT_BASE64 = /[^A-Za-z0-9+/]/\n\n/**\n * Whether `data` is standard base64 and nothing else: its alphabet, then at\n * most two `=` of padding; no `data:` prefix, no line breaks.\n *\n * Scanned for a character outside the alphabet rather than matched whole: a\n * picture is megabytes of base64, and a whole-string pattern with a greedy\n * loop can exhaust the regular expression engine's backtrack stack on input\n * that large, which throws a RangeError instead of answering.\n */\nfunction isBareBase64(data: string): boolean {\n const body = data.endsWith('==') ? data.slice(0, -2) : data.endsWith('=') ? data.slice(0, -1) : data\n return body.length > 0 && !NOT_BASE64.test(body)\n}\n\n/**\n * The picture guard (AGL-2916), applied to the messages before any adapter\n * sees them. A picture rides only a user turn, only to a model that reads\n * pictures, only in a format every such model takes, as bare base64 within\n * the size a provider accepts, and no more of them than a request may carry.\n * `readsImages` is asked only when a turn carries a picture. Exported so a\n * spec and a door can assert a request's shape without a provider.\n */\nexport function validateAiMessages(messages: readonly AiMessage[], readsImages: () => boolean): void {\n messages.forEach((message, index) => {\n if (typeof message.content === 'string') return\n if (!Array.isArray(message.content) || message.content.length === 0) {\n throw new AiRequestShapeError(`message ${index} has neither text nor parts`)\n }\n for (const part of message.content) {\n if (part.type === 'text') {\n if (typeof part.text !== 'string') {\n throw new AiRequestShapeError(`message ${index} has a text part with no text`)\n }\n continue\n }\n if (part.type !== 'image') {\n throw new AiRequestShapeError(`message ${index} has a part of an unknown type`)\n }\n if (message.role !== 'user') {\n throw new AiRequestShapeError(`message ${index} is an assistant turn and carries a picture`)\n }\n if (!(AI_IMAGE_MEDIA_TYPES as readonly string[]).includes(part.mediaType)) {\n throw new AiRequestShapeError(`message ${index} carries a picture of an unsupported type`)\n }\n if (typeof part.data !== 'string') {\n throw new AiRequestShapeError(`message ${index} carries a picture that is not bare base64`)\n }\n // The size first: it needs only the length, so an oversized picture is\n // refused without reading its bytes.\n if (aiBase64Bytes(part.data) > AI_IMAGE_MAX_BYTES) {\n throw new AiRequestShapeError(\n `message ${index} carries a picture over ${AI_IMAGE_MAX_BYTES} bytes`,\n )\n }\n if (!isBareBase64(part.data)) {\n throw new AiRequestShapeError(`message ${index} carries a picture that is not bare base64`)\n }\n }\n })\n const images = aiMessageImages(messages).length\n if (images > AI_REQUEST_MAX_IMAGES) {\n throw new AiRequestShapeError(`${images} pictures; a request carries at most ${AI_REQUEST_MAX_IMAGES}`)\n }\n if (images > 0 && !readsImages()) {\n throw new AiRequestShapeError('the request carries a picture and its model does not read pictures')\n }\n}\n\n/**\n * Characters to a token when a prompt is measured rather than tokenized\n * (AGL-2937). Four is the high end of English prose, so reading a prefix\n * through it errs LOW on the token count — and a prefix counts as cached\n * only when it clears its model's minimum even at that reading. Erring the\n * other way would let a prompt claim a cache it does not get.\n */\nexport const AI_CACHE_PREFIX_TOKEN_CHARS = 4\n\n/**\n * The characters inside the cached span of a request: the tools, which a\n * provider renders ahead of the system blocks and which therefore sit in\n * every prefix, plus every system block up to and including the last\n * breakpoint. Zero when the request marks no breakpoint at all.\n */\nexport function aiCachedPrefixChars(\n system: readonly AiSystemBlock[],\n tools: readonly AiTool[] = [],\n): number {\n const lastBreakpoint = system.reduce(\n (last, block, index) => (block.cacheBreakpoint ? index : last),\n -1,\n )\n if (lastBreakpoint < 0) return 0\n const toolChars = tools.reduce((total, tool) => total + JSON.stringify(tool).length, 0)\n return system\n .slice(0, lastBreakpoint + 1)\n .reduce((total, block) => total + block.text.length, toolChars)\n}\n\n/**\n * Whether this request's cached span is long enough for the model to cache\n * it at all (AGL-2937). A prefix under the model's `cacheMinTokens` is\n * served with the breakpoints honored and nothing cached — no error, and a\n * usage report that reads as a permanent miss — so a door designed around a\n * cache has to answer this question before it believes its own markers, and\n * `runtime/ai-prompt-cache.spec.ts` answers it for every door at once.\n */\nexport function aiCachedPrefixCaches(\n model: string,\n system: readonly AiSystemBlock[],\n tools: readonly AiTool[] = [],\n): boolean {\n const chars = aiCachedPrefixChars(system, tools)\n return chars / AI_CACHE_PREFIX_TOKEN_CHARS >= aiModelCacheMinTokens(model)\n}\n\n/**\n * Whether a provider is configured to answer: registered, and its key\n * present. Every door asks this before it spends a reservation, and\n * answers 501 (or the docs fallback) when it is false.\n */\nexport function aiProviderReady(settings?: AiPluginSettings): boolean {\n return Boolean(resolveAiProvider(settings)?.readApiKey())\n}\n\n/** The provider that would answer, for a door that names it in its logs or its inventory. */\nexport function aiActiveProvider(settings?: AiPluginSettings): AiProvider | undefined {\n return resolveAiProvider(settings)\n}\n\nfunction providerFor(input: AiRuntimeOptions): { provider: AiProvider; apiKey: string } {\n const provider = input.provider ?? resolveAiProvider(input.settings)\n if (!provider) throw new Error('no AI provider is registered')\n const apiKey = provider.readApiKey()\n if (!apiKey) {\n // Every door gates on `aiProviderReady` before it gets here, so this is\n // a wiring fault rather than a customer path.\n throw new Error(`${provider.apiKeyEnv} is not set`)\n }\n return { provider, apiKey }\n}\n\n/**\n * Send one request through the resolved provider.\n *\n * Streaming: resolves once the provider has ACCEPTED the request — after\n * the headers, before the first token — with an iterator of simplified\n * events the caller wraps in its own wire format. A non-2xx answer rejects\n * with `AiUpstreamError` before the iterator exists, so a door can still\n * send a plain error status and hand back its reservation.\n *\n * Non-streaming: resolves with the parsed result, a decline typed as\n * `AiRefusal` rather than thrown.\n */\nexport async function runAiRequest(\n input: AiStreamRequest,\n): Promise<AsyncIterable<AiStreamEvent>>\nexport async function runAiRequest(input: AiRequest): Promise<AiResult>\nexport async function runAiRequest(\n input: AiRequest | AiStreamRequest,\n): Promise<AsyncIterable<AiStreamEvent> | AiResult> {\n validateAiSystemBlocks(input.system)\n const { provider, apiKey } = providerFor(input)\n validateAiMessages(input.messages, () => aiModelReadsImages(provider, input.model))\n const { stream, settings: _settings, provider: _provider, ...request } = input\n aiWarnIfReplayAskedInDeployment()\n const cacheTtl = request.cacheTtl ?? aiDevCacheTtl()\n const providerRequest: AiProviderRequest = { ...request, ...(cacheTtl ? { cacheTtl } : {}), apiKey }\n try {\n if (aiIsDeployedRuntime()) {\n return await (stream ? provider.stream(providerRequest) : provider.complete(providerRequest))\n }\n return await runInDevelopment(provider, providerRequest, stream)\n } catch (error) {\n await reportAccountProblem(provider, error)\n throw error\n }\n}\n\n/**\n * A request in a development process (AGL-3660): through the replay cache,\n * batch mode and the first-request-warms-the-prefix rule when this process\n * asked for them (`ai-dev-env.ts`), straight to the provider when it asked\n * for none. The file-backed half is loaded only when a layer is on, so a dev\n * server that never asked loads nothing it did not before.\n */\nasync function runInDevelopment(\n provider: AiProvider,\n request: AiProviderRequest,\n stream: boolean,\n): Promise<AsyncIterable<AiStreamEvent> | AiResult> {\n const mode = aiDevReplayMode()\n const batch = !stream && aiLiveBatchWanted() && Boolean(provider.completeBatch)\n if (mode === 'off' && !batch) {\n return stream ? provider.stream(request) : provider.complete(request)\n }\n const dev = await import('./ai-dev-replay')\n const ttlMs = request.cacheTtl === '1h' ? 60 * 60_000 : 5 * 60_000\n const prefix = dev.aiCachedPrefixKey(request)\n if (stream) {\n const live = () => dev.aiDevWarmPrefixFirst(prefix, ttlMs, () => provider.stream(request))\n return mode === 'off' ? live() : dev.aiDevReplayStream(provider, request, mode, live)\n }\n const live = async (): Promise<AiResult> => {\n const result = batch\n ? await aiCompleteInBatch(provider, request)\n : await dev.aiDevWarmPrefixFirst(prefix, ttlMs, () => provider.complete(request))\n dev.aiDevCountLive(result.usage, request.model, batch ? AI_BATCH_PRICE_FACTOR : 1)\n return result\n }\n return mode === 'off' ? live() : dev.aiDevReplayComplete(provider, request, mode, live)\n}\n\n/** What a batch's discount leaves of a request's price, for the development count. */\nconst AI_BATCH_PRICE_FACTOR = 0.5\n\n/**\n * Tells the operator when the failure is the platform's provider account —\n * a refused key, no credit — rather than the request (AGL-3377). Once per\n * provider and problem per window; loaded lazily so the runtime pulls no\n * Admin SDK until an account is actually broken. Never throws.\n */\nasync function reportAccountProblem(provider: AiProvider, error: unknown): Promise<void> {\n if (!(error instanceof AiUpstreamError) || !error.accountProblem) return\n try {\n const [{ raiseOperatorAlert }, { AI_PROVIDER_UNAVAILABLE }] = await Promise.all([\n import('./operator-alerts-seam'),\n import('../operator-alerts'),\n ])\n await raiseOperatorAlert(AI_PROVIDER_UNAVAILABLE, {\n dedupeKey: `${provider.id}:${error.accountProblem}`,\n context: {\n provider: provider.label,\n keyEnv: provider.apiKeyEnv,\n reason:\n error.accountProblem === 'credentials'\n ? `its API key was refused (HTTP ${error.status ?? 'unknown'})`\n : 'the account is out of credit',\n },\n })\n } catch (alertError) {\n console.error('[ai] the provider account alert could not be raised', alertError)\n }\n}\n"],"names":["aiModelCacheMinTokens","AI_IMAGE_MAX_BYTES","AI_IMAGE_MEDIA_TYPES","AI_REQUEST_MAX_IMAGES","AiRequestShapeError","AiUpstreamError","aiBase64Bytes","aiMessageImages","resolveAiProvider","aiDevCacheTtl","aiDevReplayMode","aiIsDeployedRuntime","aiLiveBatchWanted","aiWarnIfReplayAskedInDeployment","aiCompleteInBatch","AI_UPSTREAM_FAILURE_COPY","AI_MAX_CACHE_BREAKPOINTS","AI_ACCEPTABLE_USE_BLOCK","validateAiSystemBlocks","system","breakpoints","filter","block","cacheBreakpoint","length","lastBreakpoint","reduce","last","index","forEach","volatile","firstSite","findIndex","site","aiModelReadsImages","provider","model","models","some","descriptor","id","capabilities","vision","aiRouteReadsImages","settings","NOT_BASE64","isBareBase64","data","body","endsWith","slice","test","validateAiMessages","messages","readsImages","message","content","Array","isArray","part","type","text","role","includes","mediaType","images","AI_CACHE_PREFIX_TOKEN_CHARS","aiCachedPrefixChars","tools","toolChars","total","tool","JSON","stringify","aiCachedPrefixCaches","chars","aiProviderReady","Boolean","readApiKey","aiActiveProvider","providerFor","input","Error","apiKey","apiKeyEnv","runAiRequest","request","stream","_settings","_provider","cacheTtl","providerRequest","complete","runInDevelopment","error","reportAccountProblem","mode","batch","completeBatch","dev","ttlMs","prefix","aiCachedPrefixKey","live","aiDevWarmPrefixFirst","aiDevReplayStream","result","aiDevCountLive","usage","AI_BATCH_PRICE_FACTOR","aiDevReplayComplete","accountProblem","raiseOperatorAlert","AI_PROVIDER_UNAVAILABLE","Promise","all","dedupeKey","context","label","keyEnv","reason","status","alertError","console"],"mappings":"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"}
1
+ {"version":3,"sources":["../../../../../../../libs/plugins/ai/src/lib/runtime/ai-runtime.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\nimport { aiCatalogEntry, aiModelCacheMinTokens } from '../providers/catalog'\nimport {\n AI_IMAGE_MAX_BYTES,\n AI_IMAGE_MEDIA_TYPES,\n AI_REQUEST_MAX_IMAGES,\n AiRequestShapeError,\n AiUpstreamError,\n aiBase64Bytes,\n aiMessageImages,\n type AiMessage,\n type AiProvider,\n type AiProviderRequest,\n type AiRequestBase,\n type AiResult,\n type AiStreamEvent,\n type AiSystemBlock,\n type AiTool,\n} from '../providers/contract'\nimport { resolveAiProvider, type AiPluginSettings } from '../providers/routing'\nimport {\n aiDevCacheTtl,\n aiDevReplayMode,\n aiIsDeployedRuntime,\n aiLiveBatchWanted,\n aiUnderLiveRun,\n aiWarnIfReplayAskedInDeployment,\n} from './ai-dev-env'\nimport { aiCompleteInBatch } from './ai-live-batch'\n\nexport {\n AI_UPSTREAM_FAILURE_COPY,\n AiRequestShapeError,\n AiUpstreamError,\n type AiCompletion,\n type AiEffort,\n type AiImagePart,\n type AiMessage,\n type AiMessagePart,\n type AiRefusal,\n type AiRequestBase,\n type AiResult,\n type AiStreamEvent,\n type AiSystemBlock,\n type AiThinking,\n type AiTool,\n type AiToolUse,\n type AiUsage,\n} from '../providers/contract'\n\n/**\n * The one runtime every AI door calls (AGL-2903), provider-generic since\n * AGL-2939.\n *\n * `/api/assist/chat` and `/api/ai/assist` each carried their own `fetch` to\n * one vendor, their own SSE parser and their own usage extraction, and a\n * third door would have been a third copy. This module owns the request\n * shape, the cache-prefix rule, the provider resolution and the error\n * boundary; an adapter owns one vendor's wire protocol; a door owns its\n * prompt, its gate ladder and its wire format.\n *\n * The cache-prefix rule (AGL-2352) is enforced HERE, before any adapter:\n * a block marked `volatile` may not sit at or before the last\n * `cacheBreakpoint`, and more breakpoints than any provider caches are\n * refused. A provider without a cache ignores the markers and keeps the\n * order, so a request that is right for the caching adapter is right for\n * every adapter.\n *\n * The picture rule (AGL-2916) is enforced here too, and for the same reason:\n * a picture on an assistant turn, a picture for a model whose provider does\n * not say it reads one, or a picture past the bounds is refused before any\n * adapter is reached, so no door can send one a provider would reject.\n */\n\n/** The most `cacheBreakpoint` markers a request may carry. */\nexport const AI_MAX_CACHE_BREAKPOINTS = 4\n\n/**\n * The acceptable-use rules every generation prompt carries (AGL-2925).\n *\n * ONE constant, appended to each door's STATIC system text — inside the\n * cached prefix on the console chat, ahead of the mode prompt on the copy\n * assistant — so the rules the model is held to are the same at every\n * door and carry no per-org byte. The Free taste is what makes them worth\n * stating: a generated page that nobody paid for is the cheapest phishing\n * kit there is, and the model declining it is the first line, ahead of the\n * abuse-report route and the lockdown key.\n *\n * The categories are the platform's own Acceptable Use Policy, restated in\n * the second person for the model. A decline from the model's safety layer\n * arrives as a refusal, which the meter counts against the account's daily\n * pause and never against its credits.\n */\nexport const AI_ACCEPTABLE_USE_BLOCK = `Acceptable use — decline these rather than write them, and say briefly why:\n- Pages, emails or copy that harvest credentials, payment details or personal data by deception (phishing, fake sign-in or checkout pages, fake \"verify your account\" notices).\n- Impersonating a real business, brand, public body or person you were not told the user represents — their name, logo, sign-in page or voice — or copy that claims an endorsement, certification or partnership that was not given to you as a fact.\n- Content that promotes or sells prohibited goods and services: weapons, illegal drugs, counterfeit goods, malware or deceptive downloads, unlicensed gambling, or adult content involving minors.\n- Scams and fraud: fake investment or crypto schemes, advance-fee offers, tech-support scams, fake charities, deceptive \"you have won\" pages, or copy built to defraud.\n- Harassment, threats, or hateful content targeting a person or a group.\n- Spam farms: bulk near-duplicate pages or copy whose only purpose is to game search engines or send unsolicited mail.\nWhen a brief is ordinary business content, write it well and do not lecture.`\n\n/** A door's request, with the org's settings when the door knows the org. */\ninterface AiRuntimeOptions {\n /** The org's resolved `pluginSettings/ai`, for its provider and model choices. */\n settings?: AiPluginSettings\n /** A provider chosen by the caller, ahead of every setting; specs use it. */\n provider?: AiProvider\n}\n\nexport interface AiRequest extends AiRequestBase, AiRuntimeOptions {\n stream: false\n}\n\nexport interface AiStreamRequest extends AiRequestBase, AiRuntimeOptions {\n stream: true\n}\n\n/**\n * The cache guard, applied to the system blocks before any adapter sees\n * them. Exported so a spec and a door can assert a prompt's shape without\n * a provider.\n */\nexport function validateAiSystemBlocks(system: readonly AiSystemBlock[]): void {\n const breakpoints = system.filter((block) => block.cacheBreakpoint)\n if (breakpoints.length > AI_MAX_CACHE_BREAKPOINTS) {\n throw new AiRequestShapeError(\n `${breakpoints.length} cache breakpoints; at most ${AI_MAX_CACHE_BREAKPOINTS} are cached`,\n )\n }\n // Caching is a prefix match, so the LAST breakpoint decides what is\n // cached, and every block at or before it is inside the cached prefix. A\n // volatile block anywhere in that span — including one that is itself\n // the breakpoint — puts a per-request byte into the prefix (AGL-2352).\n const lastBreakpoint = system.reduce(\n (last, block, index) => (block.cacheBreakpoint ? index : last),\n -1,\n )\n system.forEach((block, index) => {\n if (block.volatile && index <= lastBreakpoint) {\n throw new AiRequestShapeError(\n `system block ${index} is volatile but sits inside the cached prefix (last breakpoint at block ${lastBreakpoint})`,\n )\n }\n })\n // A site's block is cached per site (AGL-3661), so it closes the cached\n // span: a platform-wide breakpoint after it would be keyed on the site too,\n // and every workspace would warm its own copy of a shared prompt.\n const firstSite = system.findIndex((block) => block.site && block.cacheBreakpoint)\n if (firstSite >= 0 && firstSite !== lastBreakpoint) {\n throw new AiRequestShapeError(\n `system block ${firstSite} is a site's cached block but breakpoint ${lastBreakpoint} follows it`,\n )\n }\n}\n\n/**\n * Whether a provider's model reads a picture in a user turn (AGL-2916): what\n * the provider's own descriptor of the model says. A model the provider does\n * not describe reads none, so an adopter's provider is never sent a picture\n * it did not say it can take.\n */\nexport function aiModelReadsImages(provider: AiProvider, model: string): boolean {\n return provider\n .models()\n .some((descriptor) => descriptor.id === model && descriptor.capabilities.vision === true)\n}\n\n/**\n * Whether a model a door resolved reads pictures on the provider that would\n * serve the request: what a door asks before it attaches one, so a model that\n * does not is sent the text alone rather than refused.\n */\nexport function aiRouteReadsImages(model: string, settings?: AiPluginSettings): boolean {\n const provider = resolveAiProvider(settings)\n return provider ? aiModelReadsImages(provider, model) : false\n}\n\n/** A character standard base64 does not use, outside its padding. */\nconst NOT_BASE64 = /[^A-Za-z0-9+/]/\n\n/**\n * Whether `data` is standard base64 and nothing else: its alphabet, then at\n * most two `=` of padding; no `data:` prefix, no line breaks.\n *\n * Scanned for a character outside the alphabet rather than matched whole: a\n * picture is megabytes of base64, and a whole-string pattern with a greedy\n * loop can exhaust the regular expression engine's backtrack stack on input\n * that large, which throws a RangeError instead of answering.\n */\nfunction isBareBase64(data: string): boolean {\n const body = data.endsWith('==') ? data.slice(0, -2) : data.endsWith('=') ? data.slice(0, -1) : data\n return body.length > 0 && !NOT_BASE64.test(body)\n}\n\n/**\n * The picture guard (AGL-2916), applied to the messages before any adapter\n * sees them. A picture rides only a user turn, only to a model that reads\n * pictures, only in a format every such model takes, as bare base64 within\n * the size a provider accepts, and no more of them than a request may carry.\n * `readsImages` is asked only when a turn carries a picture. Exported so a\n * spec and a door can assert a request's shape without a provider.\n */\nexport function validateAiMessages(messages: readonly AiMessage[], readsImages: () => boolean): void {\n messages.forEach((message, index) => {\n if (typeof message.content === 'string') return\n if (!Array.isArray(message.content) || message.content.length === 0) {\n throw new AiRequestShapeError(`message ${index} has neither text nor parts`)\n }\n for (const part of message.content) {\n if (part.type === 'text') {\n if (typeof part.text !== 'string') {\n throw new AiRequestShapeError(`message ${index} has a text part with no text`)\n }\n continue\n }\n if (part.type !== 'image') {\n throw new AiRequestShapeError(`message ${index} has a part of an unknown type`)\n }\n if (message.role !== 'user') {\n throw new AiRequestShapeError(`message ${index} is an assistant turn and carries a picture`)\n }\n if (!(AI_IMAGE_MEDIA_TYPES as readonly string[]).includes(part.mediaType)) {\n throw new AiRequestShapeError(`message ${index} carries a picture of an unsupported type`)\n }\n if (typeof part.data !== 'string') {\n throw new AiRequestShapeError(`message ${index} carries a picture that is not bare base64`)\n }\n // The size first: it needs only the length, so an oversized picture is\n // refused without reading its bytes.\n if (aiBase64Bytes(part.data) > AI_IMAGE_MAX_BYTES) {\n throw new AiRequestShapeError(\n `message ${index} carries a picture over ${AI_IMAGE_MAX_BYTES} bytes`,\n )\n }\n if (!isBareBase64(part.data)) {\n throw new AiRequestShapeError(`message ${index} carries a picture that is not bare base64`)\n }\n }\n })\n const images = aiMessageImages(messages).length\n if (images > AI_REQUEST_MAX_IMAGES) {\n throw new AiRequestShapeError(`${images} pictures; a request carries at most ${AI_REQUEST_MAX_IMAGES}`)\n }\n if (images > 0 && !readsImages()) {\n throw new AiRequestShapeError('the request carries a picture and its model does not read pictures')\n }\n}\n\n/**\n * Characters to a token when a prompt is measured rather than tokenized\n * (AGL-2937). Four is the high end of English prose, so reading a prefix\n * through it errs LOW on the token count — and a prefix counts as cached\n * only when it clears its model's minimum even at that reading. Erring the\n * other way would let a prompt claim a cache it does not get.\n */\nexport const AI_CACHE_PREFIX_TOKEN_CHARS = 4\n\n/**\n * The characters inside the cached span of a request: the tools, which a\n * provider renders ahead of the system blocks and which therefore sit in\n * every prefix, plus every system block up to and including the last\n * breakpoint. Zero when the request marks no breakpoint at all.\n */\nexport function aiCachedPrefixChars(\n system: readonly AiSystemBlock[],\n tools: readonly AiTool[] = [],\n): number {\n const lastBreakpoint = system.reduce(\n (last, block, index) => (block.cacheBreakpoint ? index : last),\n -1,\n )\n if (lastBreakpoint < 0) return 0\n const toolChars = tools.reduce((total, tool) => total + JSON.stringify(tool).length, 0)\n return system\n .slice(0, lastBreakpoint + 1)\n .reduce((total, block) => total + block.text.length, toolChars)\n}\n\n/**\n * Whether this request's cached span is long enough for the model to cache\n * it at all (AGL-2937). A prefix under the model's `cacheMinTokens` is\n * served with the breakpoints honored and nothing cached — no error, and a\n * usage report that reads as a permanent miss — so a door designed around a\n * cache has to answer this question before it believes its own markers, and\n * `runtime/ai-prompt-cache.spec.ts` answers it for every door at once.\n */\nexport function aiCachedPrefixCaches(\n model: string,\n system: readonly AiSystemBlock[],\n tools: readonly AiTool[] = [],\n): boolean {\n const chars = aiCachedPrefixChars(system, tools)\n return chars / AI_CACHE_PREFIX_TOKEN_CHARS >= aiModelCacheMinTokens(model)\n}\n\n/**\n * Whether a provider is configured to answer: registered, and its key\n * present. Every door asks this before it spends a reservation, and\n * answers 501 (or the docs fallback) when it is false.\n */\nexport function aiProviderReady(settings?: AiPluginSettings): boolean {\n return Boolean(resolveAiProvider(settings)?.readApiKey())\n}\n\n/** The provider that would answer, for a door that names it in its logs or its inventory. */\nexport function aiActiveProvider(settings?: AiPluginSettings): AiProvider | undefined {\n return resolveAiProvider(settings)\n}\n\nfunction providerFor(input: AiRuntimeOptions): { provider: AiProvider; apiKey: string } {\n const provider = input.provider ?? resolveAiProvider(input.settings)\n if (!provider) throw new Error('no AI provider is registered')\n const apiKey = provider.readApiKey()\n if (!apiKey) {\n // Every door gates on `aiProviderReady` before it gets here, so this is\n // a wiring fault rather than a customer path.\n throw new Error(`${provider.apiKeyEnv} is not set`)\n }\n return { provider, apiKey }\n}\n\n/**\n * Send one request through the resolved provider.\n *\n * Streaming: resolves once the provider has ACCEPTED the request — after\n * the headers, before the first token — with an iterator of simplified\n * events the caller wraps in its own wire format. A non-2xx answer rejects\n * with `AiUpstreamError` before the iterator exists, so a door can still\n * send a plain error status and hand back its reservation.\n *\n * Non-streaming: resolves with the parsed result, a decline typed as\n * `AiRefusal` rather than thrown.\n */\nexport async function runAiRequest(\n input: AiStreamRequest,\n): Promise<AsyncIterable<AiStreamEvent>>\nexport async function runAiRequest(input: AiRequest): Promise<AiResult>\nexport async function runAiRequest(\n input: AiRequest | AiStreamRequest,\n): Promise<AsyncIterable<AiStreamEvent> | AiResult> {\n validateAiSystemBlocks(input.system)\n refuseRetiredModelInLiveRun(input.model)\n const { provider, apiKey } = providerFor(input)\n validateAiMessages(input.messages, () => aiModelReadsImages(provider, input.model))\n const { stream, settings: _settings, provider: _provider, ...request } = input\n aiWarnIfReplayAskedInDeployment()\n const cacheTtl = request.cacheTtl ?? aiDevCacheTtl()\n const providerRequest: AiProviderRequest = { ...request, ...(cacheTtl ? { cacheTtl } : {}), apiKey }\n try {\n if (aiIsDeployedRuntime()) {\n return await (stream ? provider.stream(providerRequest) : provider.complete(providerRequest))\n }\n return await runInDevelopment(provider, providerRequest, stream)\n } catch (error) {\n await reportAccountProblem(provider, error)\n throw error\n }\n}\n\n/**\n * A live development run that names a retired model is refused before it\n * spends (AGL-3781). Routing serves a retired id's successor wherever a\n * setting or an override names it, so a retired id reaching here was asked\n * for by name — an override left in a run, or a comparison a spec kept — and\n * would bill a model production no longer serves.\n */\nfunction refuseRetiredModelInLiveRun(model: string): void {\n if (!aiUnderLiveRun()) return\n const successor = aiCatalogEntry(model)?.successor\n if (successor) {\n throw new AiRequestShapeError(`${model} is retired; ${successor} serves in its place`)\n }\n}\n\n/**\n * A request in a development process (AGL-3660): through the replay cache,\n * batch mode and the first-request-warms-the-prefix rule when this process\n * asked for them (`ai-dev-env.ts`), straight to the provider when it asked\n * for none. The file-backed half is loaded only when a layer is on, so a dev\n * server that never asked loads nothing it did not before.\n */\nasync function runInDevelopment(\n provider: AiProvider,\n request: AiProviderRequest,\n stream: boolean,\n): Promise<AsyncIterable<AiStreamEvent> | AiResult> {\n const mode = aiDevReplayMode()\n const batch = !stream && aiLiveBatchWanted() && Boolean(provider.completeBatch)\n if (mode === 'off' && !batch) {\n return stream ? provider.stream(request) : provider.complete(request)\n }\n const dev = await import('./ai-dev-replay')\n const ttlMs = request.cacheTtl === '1h' ? 60 * 60_000 : 5 * 60_000\n const prefix = dev.aiCachedPrefixKey(request)\n if (stream) {\n const live = () => dev.aiDevWarmPrefixFirst(prefix, ttlMs, () => provider.stream(request))\n return mode === 'off' ? live() : dev.aiDevReplayStream(provider, request, mode, live)\n }\n const live = async (): Promise<AiResult> => {\n const result = batch\n ? await aiCompleteInBatch(provider, request)\n : await dev.aiDevWarmPrefixFirst(prefix, ttlMs, () => provider.complete(request))\n dev.aiDevCountLive(result.usage, request.model, batch ? AI_BATCH_PRICE_FACTOR : 1)\n return result\n }\n return mode === 'off' ? live() : dev.aiDevReplayComplete(provider, request, mode, live)\n}\n\n/** What a batch's discount leaves of a request's price, for the development count. */\nconst AI_BATCH_PRICE_FACTOR = 0.5\n\n/**\n * Tells the operator when the failure is the platform's provider account —\n * a refused key, no credit — rather than the request (AGL-3377). Once per\n * provider and problem per window; loaded lazily so the runtime pulls no\n * Admin SDK until an account is actually broken. Never throws.\n */\nasync function reportAccountProblem(provider: AiProvider, error: unknown): Promise<void> {\n if (!(error instanceof AiUpstreamError) || !error.accountProblem) return\n try {\n const [{ raiseOperatorAlert }, { AI_PROVIDER_UNAVAILABLE }] = await Promise.all([\n import('./operator-alerts-seam'),\n import('../operator-alerts'),\n ])\n await raiseOperatorAlert(AI_PROVIDER_UNAVAILABLE, {\n dedupeKey: `${provider.id}:${error.accountProblem}`,\n context: {\n provider: provider.label,\n keyEnv: provider.apiKeyEnv,\n reason:\n error.accountProblem === 'credentials'\n ? `its API key was refused (HTTP ${error.status ?? 'unknown'})`\n : 'the account is out of credit',\n },\n })\n } catch (alertError) {\n console.error('[ai] the provider account alert could not be raised', alertError)\n }\n}\n"],"names":["aiCatalogEntry","aiModelCacheMinTokens","AI_IMAGE_MAX_BYTES","AI_IMAGE_MEDIA_TYPES","AI_REQUEST_MAX_IMAGES","AiRequestShapeError","AiUpstreamError","aiBase64Bytes","aiMessageImages","resolveAiProvider","aiDevCacheTtl","aiDevReplayMode","aiIsDeployedRuntime","aiLiveBatchWanted","aiUnderLiveRun","aiWarnIfReplayAskedInDeployment","aiCompleteInBatch","AI_UPSTREAM_FAILURE_COPY","AI_MAX_CACHE_BREAKPOINTS","AI_ACCEPTABLE_USE_BLOCK","validateAiSystemBlocks","system","breakpoints","filter","block","cacheBreakpoint","length","lastBreakpoint","reduce","last","index","forEach","volatile","firstSite","findIndex","site","aiModelReadsImages","provider","model","models","some","descriptor","id","capabilities","vision","aiRouteReadsImages","settings","NOT_BASE64","isBareBase64","data","body","endsWith","slice","test","validateAiMessages","messages","readsImages","message","content","Array","isArray","part","type","text","role","includes","mediaType","images","AI_CACHE_PREFIX_TOKEN_CHARS","aiCachedPrefixChars","tools","toolChars","total","tool","JSON","stringify","aiCachedPrefixCaches","chars","aiProviderReady","Boolean","readApiKey","aiActiveProvider","providerFor","input","Error","apiKey","apiKeyEnv","runAiRequest","request","refuseRetiredModelInLiveRun","stream","_settings","_provider","cacheTtl","providerRequest","complete","runInDevelopment","error","reportAccountProblem","successor","mode","batch","completeBatch","dev","ttlMs","prefix","aiCachedPrefixKey","live","aiDevWarmPrefixFirst","aiDevReplayStream","result","aiDevCountLive","usage","AI_BATCH_PRICE_FACTOR","aiDevReplayComplete","accountProblem","raiseOperatorAlert","AI_PROVIDER_UNAVAILABLE","Promise","all","dedupeKey","context","label","keyEnv","reason","status","alertError","console"],"mappings":"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"}
@@ -14,7 +14,7 @@
14
14
  * See the License for the specific language governing permissions and
15
15
  * limitations under the License.
16
16
  */ import { _ as _extends } from "@swc/helpers/_/_extends";
17
- import { AI_ROUTING_TABLE } from "../providers/routing.js";
17
+ import { AI_ROUTING_TABLE, aiRouteSettings } from "../providers/routing.js";
18
18
  import { AI_SEO_FIELDS_TOOL_NAME, aiSeoFieldsTool, checkAiSeoFields, orderedSeoListingFields } from "../tools/ai-seo-tool.js";
19
19
  import { runValidatedGeneration } from "./ai-doctrine.js";
20
20
  /**
@@ -175,9 +175,10 @@ import { runValidatedGeneration } from "./ai-doctrine.js";
175
175
  const hasImage = Boolean(input.image);
176
176
  const fields = orderedSeoListingFields((_input_fields = input.fields) != null ? _input_fields : AI_SEO_DEFAULT_FIELDS).filter((key)=>key !== 'imageAlt' || hasImage);
177
177
  const keywords = (_input_keywords = input.keywords) != null ? _input_keywords : [];
178
+ const step = (_input_step = input.step) != null ? _input_step : 'job.seo';
178
179
  return runValidatedGeneration('seo-fields', _extends({
179
- step: (_input_step = input.step) != null ? _input_step : 'job.seo'
180
- }, input.settings ? {
180
+ step
181
+ }, aiRouteSettings(step), input.settings ? {
181
182
  settings: input.settings
182
183
  } : {}, input.model ? {
183
184
  model: input.model
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../../../../../libs/plugins/ai/src/lib/runtime/seo-fields.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\nimport type { SeoListingFieldKey } from '@aglyn/aglyn/app-utils/seo-listing-fields'\nimport type { AiSeoFieldValues } from '../model/ai-seo'\nimport type { AiStepKind } from '../providers/catalog'\nimport type { AiProvider } from '../providers/contract'\nimport { AI_ROUTING_TABLE, type AiPluginSettings } from '../providers/routing'\nimport {\n AI_SEO_FIELDS_TOOL_NAME,\n aiSeoFieldsTool,\n checkAiSeoFields,\n orderedSeoListingFields,\n} from '../tools/ai-seo-tool'\nimport { runValidatedGeneration, type AiValidatedGeneration } from './ai-doctrine'\nimport type { AiSystemBlock } from './ai-runtime'\n\n/**\n * SEO fields by AI (AGL-2910): a search listing — title, description,\n * breadcrumb label, image description — written from what a page or a\n * product actually says, and held to the lengths its editor enforces.\n *\n * `generateSeoFields` is the one entry point. The \"Write SEO\" card on a\n * page's SEO panel and on a product's listing calls it through an `seo` job,\n * and a page generator calls it for the title and description of the page it\n * builds. It proposes; it never writes a listing anywhere.\n *\n * The tool the model answers through, and the check that holds the answer to\n * the editor, are `tools/ai-seo-tool.ts`: the tool is built from the editor's\n * own field list, so a field and its length are said once.\n *\n * ## The request\n *\n * The fast tier through the routing table (`job.seo`), never a model\n * literal, and the building doctrine's generation call\n * (`runValidatedGeneration`, AGL-3009): the doctrine's cached block, which\n * carries the acceptable-use rules, then these rules, cached after it. The\n * page's text, its current listing, the site's name and the target keywords\n * ride in the user turn, so no byte of a site sits inside the cached prefix,\n * and no site inventory is sent: a listing is written from its page.\n */\n\n/* ------------------------------------------------------------------------ *\n * The request\n * ------------------------------------------------------------------------ */\n\n/**\n * The output budget, from the routing table. The largest answer the tool\n * accepts — every field at its limit — is under 300 tokens of JSON\n * (`ai-job-seo-step.spec.ts` measures it); the rest is headroom, and the fast\n * tier thinks in none of it.\n */\nexport const AI_SEO_FIELDS_MAX_TOKENS = AI_ROUTING_TABLE['job.seo'].maxTokens\n\n/** How many other pages' titles a prompt lists for the model to avoid. */\nconst OTHER_TITLES_LISTED = 40\n\n/**\n * What one listing field's rule ADDS to the tool's own description of that\n * field (AGL-2937), stated only when the field is asked for.\n *\n * The schema the answer arrives through is built from the editor's field\n * catalog, and it already names each field, says what the value is for and\n * states its length. It rides in the same request, ahead of these blocks, so\n * a rule that restated it would be the same sentence billed twice on a door\n * whose prompt no model will cache. Each rule here is therefore only what\n * the schema does not say: how to write the field rather than what it is.\n * The image description has no rule at all, because its description already\n * carries both halves of one — what it describes, and when to answer null.\n *\n * `runtime/ai-prompt-cache.spec.ts` holds the pair together: whatever\n * `checkAiSeoFields` can refuse an answer for, the request has to state\n * somewhere, in a block or in the schema.\n */\nconst AI_SEO_FIELD_RULES: Readonly<Partial<Record<SeoListingFieldKey, string>>> = {\n title:\n 'The title says what the page is, specifically and in plain words; add the site name only where it helps.',\n description:\n 'The description is one or two sentences: no quotation marks, no emoji, no capitals for emphasis.',\n breadcrumb: 'The breadcrumb label is one to three words.',\n}\n\n/** What a listing request carries, and therefore which rules it is sent. */\nexport interface AiSeoFieldsInstructionShape {\n fields: readonly SeoListingFieldKey[]\n keywords: boolean\n otherTitles: boolean\n}\n\n/**\n * The rules for one listing request.\n *\n * Byte-identical for every request of the same SHAPE, and identical across\n * workspaces at every shape: no site's name, text or listing is in here, so\n * two tenants writing the same kind of listing send the same rules. That is\n * the invariant `runtime/ai-prompt-cache.spec.ts` holds, and it is weaker\n * than \"one block for everything\" on purpose — a rule about target keywords\n * is worth nothing to a request that carries none, and `job.seo` runs on a\n * model that caches no prompt this short, so an unread rule is simply paid\n * for at full input rate on every attempt and every re-ask.\n */\nexport function aiSeoFieldsInstructions(\n shape: AiSeoFieldsInstructionShape,\n): AiSystemBlock[] {\n const fields = orderedSeoListingFields(shape.fields)\n const rules = [\n 'Write only from the text you are given. Never invent a fact, a price, a name, a place, an offer or a claim the text does not state.',\n ...fields.map((key) => AI_SEO_FIELD_RULES[key]).filter((rule): rule is string => Boolean(rule)),\n // Every field a person reads is held to this, keywords or none, so it is\n // stated on every shape rather than inside the keyword rule it used to\n // ride in: `checkAiSeoFields` refuses a repeat in a listing that named\n // no keywords too.\n 'Never repeat the same word three times in one field.',\n ...(shape.keywords\n ? [\n 'A target keyword goes in only where the page is about it and it reads naturally: at most once in the title and once in the description. Never list keywords, and leave out a keyword the page is not about.',\n ]\n : []),\n ...(shape.otherTitles\n ? ['Do not reuse a title another page of the site already uses.']\n : []),\n 'Write in the language of the page text.',\n ]\n return [\n {\n text:\n 'You write the search listing for one page or one product of a website: what a search result shows.\\n\\n' +\n `Answer by calling ${AI_SEO_FIELDS_TOOL_NAME} exactly once. A reply in prose cannot be used.\\n\\n` +\n `Rules:\\n${rules.map((rule) => `- ${rule}`).join('\\n')}`,\n cacheBreakpoint: true,\n },\n ]\n}\n\n/** The rules for a listing of the default shape, for a door that names no other. */\nexport const AI_SEO_FIELDS_INSTRUCTIONS: AiSystemBlock[] = aiSeoFieldsInstructions({\n fields: ['title', 'description', 'breadcrumb'],\n keywords: false,\n otherTitles: false,\n})\n\n/**\n * What the site itself is, in the words of whoever set it up (AGL-2918).\n *\n * A page's own text says what the page is; it does not say who the page is\n * written for. On a site whose pages have just been generated that gap is at\n * its widest — every page reads like the same competent stranger wrote it —\n * so the words the owner used are carried into the listing rather than\n * inferred back out of the copy they produced.\n *\n * It rides in the user turn and nowhere else: it is one site's own words, so\n * putting it in a rules block would key the platform's cache entry on a\n * tenant's sentence, and the rules already say to write from what is given.\n */\nexport interface AiSeoSiteWords {\n /** What kind of site it is: \"a neighborhood dog groomer\". */\n about?: string | null\n /** Who it is for: \"local dog owners who want a regular groom booked online\". */\n audience?: string | null\n}\n\nexport interface AiSeoFieldsPromptInput {\n subject: { kind: 'screen' | 'product'; name: string; path?: string | null }\n /** The site's name, as its visitors see it. */\n brand: string\n /** What the site is and who it is for, where the caller knows (AGL-2918). */\n site?: AiSeoSiteWords | null\n /** What the page or the product says: a page's Markdown, a product's description. */\n text: string\n fields: readonly SeoListingFieldKey[]\n current?: AiSeoFieldValues\n /**\n * What is known about the share image, when the listing has one: the\n * description it carries, and the page text around the same picture where\n * the page shows it.\n */\n image?: { alt?: string | null; context?: string | null } | null\n keywords?: readonly string[]\n otherTitles?: readonly string[]\n}\n\n/** The longest a site's own words run in a prompt; they are a phrase, not copy. */\nconst SITE_WORDS_MAX_CHARS = 160\n\n/** One of the site's own words as its line reads, or nothing when it says nothing. */\nfunction siteWordsLine(label: string, value: string | null | undefined): string {\n const text = (value ?? '').replace(/\\s+/g, ' ').trim().slice(0, SITE_WORDS_MAX_CHARS).trim()\n return text ? `${label}: ${text}` : ''\n}\n\n/** The user turn: the subject, what it says now, and the page's own text. */\nexport function aiSeoFieldsPrompt(input: AiSeoFieldsPromptInput): string {\n const fields = orderedSeoListingFields(input.fields)\n const lines = [\n `Site: ${input.brand || 'untitled site'}`,\n siteWordsLine('What the site is', input.site?.about),\n siteWordsLine('Who the site is for', input.site?.audience),\n input.subject.kind === 'product'\n ? `Product: ${input.subject.name || 'untitled product'}`\n : `Page: ${input.subject.name || 'untitled page'}${input.subject.path ? ` (${input.subject.path})` : ''}`,\n `Fields to write: ${fields.join(', ')}`,\n // Only the two site lines can be empty here, and an empty one is a\n // question nobody answered rather than a blank line to send.\n ].filter(Boolean)\n const current = fields\n .map((key) => [key, input.current?.[key]?.trim()] as const)\n .filter(([, value]) => Boolean(value))\n if (current.length) {\n lines.push('Current listing:', ...current.map(([key, value]) => `- ${key}: ${value}`))\n }\n if (input.image) {\n const known = [\n input.image.alt && `its current description: ${input.image.alt}`,\n input.image.context && `the page text beside it: ${input.image.context}`,\n ]\n .filter(Boolean)\n .join('; ')\n lines.push(`Share image: ${known || 'set, and nothing is known about what it shows'}`)\n }\n if (input.keywords?.length) lines.push(`Target keywords: ${input.keywords.join(', ')}`)\n if (input.otherTitles?.length) {\n lines.push(\n 'Titles other pages already use:',\n ...input.otherTitles.slice(0, OTHER_TITLES_LISTED).map((title) => `- ${title}`),\n )\n }\n lines.push(\n '',\n input.subject.kind === 'product' ? 'Product description:' : 'Page text:',\n input.text.trim() || '(no text)',\n )\n return lines.join('\\n')\n}\n\nexport interface GenerateSeoFieldsInput extends Omit<AiSeoFieldsPromptInput, 'fields'> {\n /** The fields to write; the title, description and breadcrumb label when omitted. */\n fields?: readonly SeoListingFieldKey[]\n /** The step kind the call is routed as; `job.seo` when omitted. */\n step?: AiStepKind\n settings?: AiPluginSettings\n model?: string\n /**\n * The answer's ceiling: `AI_SEO_FIELDS_MAX_TOKENS` when omitted. A job step\n * passes the most its registered time allows on the model it runs\n * (AGL-3035).\n */\n maxTokens?: number\n signal?: AbortSignal\n provider?: AiProvider\n}\n\n/** What a listing is written with when the caller names no fields. */\nexport const AI_SEO_DEFAULT_FIELDS: readonly SeoListingFieldKey[] = ['title', 'description', 'breadcrumb']\n\n/**\n * Propose a search listing for a page or a product.\n *\n * STABLE: a page generator calls this for the title and description of a\n * page it builds (`fields: ['title', 'description']`), and the `seo` job\n * calls it for a page's or a product's whole listing. It asks the routing\n * table's `job.seo` model through the doctrine's generation call and resolves\n * to its result — `ok` with the values, `needs_input` when the answer could\n * not be held to the editor after one re-ask, `refused` when the model\n * declined. It writes nothing.\n */\nexport async function generateSeoFields(\n input: GenerateSeoFieldsInput,\n): Promise<AiValidatedGeneration<AiSeoFieldValues>> {\n const hasImage = Boolean(input.image)\n const fields = orderedSeoListingFields(input.fields ?? AI_SEO_DEFAULT_FIELDS).filter(\n (key) => key !== 'imageAlt' || hasImage,\n )\n const keywords = input.keywords ?? []\n return runValidatedGeneration('seo-fields', {\n step: input.step ?? 'job.seo',\n ...(input.settings ? { settings: input.settings } : {}),\n ...(input.model ? { model: input.model } : {}),\n instructions: aiSeoFieldsInstructions({\n fields,\n keywords: keywords.length > 0,\n otherTitles: Boolean(input.otherTitles?.length),\n }),\n messages: [{ role: 'user', content: aiSeoFieldsPrompt({ ...input, fields, keywords }) }],\n tool: aiSeoFieldsTool(fields),\n maxTokens: input.maxTokens ?? AI_SEO_FIELDS_MAX_TOKENS,\n ...(input.signal ? { signal: input.signal } : {}),\n ...(input.provider ? { provider: input.provider } : {}),\n check: (answer) =>\n checkAiSeoFields(answer, {\n fields,\n hasImage,\n keywords,\n otherTitles: input.otherTitles,\n }),\n })\n}\n"],"names":["AI_ROUTING_TABLE","AI_SEO_FIELDS_TOOL_NAME","aiSeoFieldsTool","checkAiSeoFields","orderedSeoListingFields","runValidatedGeneration","AI_SEO_FIELDS_MAX_TOKENS","maxTokens","OTHER_TITLES_LISTED","AI_SEO_FIELD_RULES","title","description","breadcrumb","aiSeoFieldsInstructions","shape","fields","rules","map","key","filter","rule","Boolean","keywords","otherTitles","text","join","cacheBreakpoint","AI_SEO_FIELDS_INSTRUCTIONS","SITE_WORDS_MAX_CHARS","siteWordsLine","label","value","replace","trim","slice","aiSeoFieldsPrompt","input","lines","brand","site","about","audience","subject","kind","name","path","current","length","push","image","known","alt","context","AI_SEO_DEFAULT_FIELDS","generateSeoFields","hasImage","step","settings","model","instructions","messages","role","content","tool","signal","provider","check","answer"],"mappings":"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"}
1
+ {"version":3,"sources":["../../../../../../../libs/plugins/ai/src/lib/runtime/seo-fields.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\nimport type { SeoListingFieldKey } from '@aglyn/aglyn/app-utils/seo-listing-fields'\nimport type { AiSeoFieldValues } from '../model/ai-seo'\nimport type { AiStepKind } from '../providers/catalog'\nimport type { AiProvider } from '../providers/contract'\nimport { AI_ROUTING_TABLE, aiRouteSettings, type AiPluginSettings } from '../providers/routing'\nimport {\n AI_SEO_FIELDS_TOOL_NAME,\n aiSeoFieldsTool,\n checkAiSeoFields,\n orderedSeoListingFields,\n} from '../tools/ai-seo-tool'\nimport { runValidatedGeneration, type AiValidatedGeneration } from './ai-doctrine'\nimport type { AiSystemBlock } from './ai-runtime'\n\n/**\n * SEO fields by AI (AGL-2910): a search listing — title, description,\n * breadcrumb label, image description — written from what a page or a\n * product actually says, and held to the lengths its editor enforces.\n *\n * `generateSeoFields` is the one entry point. The \"Write SEO\" card on a\n * page's SEO panel and on a product's listing calls it through an `seo` job,\n * and a page generator calls it for the title and description of the page it\n * builds. It proposes; it never writes a listing anywhere.\n *\n * The tool the model answers through, and the check that holds the answer to\n * the editor, are `tools/ai-seo-tool.ts`: the tool is built from the editor's\n * own field list, so a field and its length are said once.\n *\n * ## The request\n *\n * The fast tier through the routing table (`job.seo`), never a model\n * literal, and the building doctrine's generation call\n * (`runValidatedGeneration`, AGL-3009): the doctrine's cached block, which\n * carries the acceptable-use rules, then these rules, cached after it. The\n * page's text, its current listing, the site's name and the target keywords\n * ride in the user turn, so no byte of a site sits inside the cached prefix,\n * and no site inventory is sent: a listing is written from its page.\n */\n\n/* ------------------------------------------------------------------------ *\n * The request\n * ------------------------------------------------------------------------ */\n\n/**\n * The output budget, from the routing table. The largest answer the tool\n * accepts — every field at its limit — is under 300 tokens of JSON\n * (`ai-job-seo-step.spec.ts` measures it); the rest is headroom, and the fast\n * tier thinks in none of it.\n */\nexport const AI_SEO_FIELDS_MAX_TOKENS = AI_ROUTING_TABLE['job.seo'].maxTokens\n\n/** How many other pages' titles a prompt lists for the model to avoid. */\nconst OTHER_TITLES_LISTED = 40\n\n/**\n * What one listing field's rule ADDS to the tool's own description of that\n * field (AGL-2937), stated only when the field is asked for.\n *\n * The schema the answer arrives through is built from the editor's field\n * catalog, and it already names each field, says what the value is for and\n * states its length. It rides in the same request, ahead of these blocks, so\n * a rule that restated it would be the same sentence billed twice on a door\n * whose prompt no model will cache. Each rule here is therefore only what\n * the schema does not say: how to write the field rather than what it is.\n * The image description has no rule at all, because its description already\n * carries both halves of one — what it describes, and when to answer null.\n *\n * `runtime/ai-prompt-cache.spec.ts` holds the pair together: whatever\n * `checkAiSeoFields` can refuse an answer for, the request has to state\n * somewhere, in a block or in the schema.\n */\nconst AI_SEO_FIELD_RULES: Readonly<Partial<Record<SeoListingFieldKey, string>>> = {\n title:\n 'The title says what the page is, specifically and in plain words; add the site name only where it helps.',\n description:\n 'The description is one or two sentences: no quotation marks, no emoji, no capitals for emphasis.',\n breadcrumb: 'The breadcrumb label is one to three words.',\n}\n\n/** What a listing request carries, and therefore which rules it is sent. */\nexport interface AiSeoFieldsInstructionShape {\n fields: readonly SeoListingFieldKey[]\n keywords: boolean\n otherTitles: boolean\n}\n\n/**\n * The rules for one listing request.\n *\n * Byte-identical for every request of the same SHAPE, and identical across\n * workspaces at every shape: no site's name, text or listing is in here, so\n * two tenants writing the same kind of listing send the same rules. That is\n * the invariant `runtime/ai-prompt-cache.spec.ts` holds, and it is weaker\n * than \"one block for everything\" on purpose — a rule about target keywords\n * is worth nothing to a request that carries none, and `job.seo` runs on a\n * model that caches no prompt this short, so an unread rule is simply paid\n * for at full input rate on every attempt and every re-ask.\n */\nexport function aiSeoFieldsInstructions(\n shape: AiSeoFieldsInstructionShape,\n): AiSystemBlock[] {\n const fields = orderedSeoListingFields(shape.fields)\n const rules = [\n 'Write only from the text you are given. Never invent a fact, a price, a name, a place, an offer or a claim the text does not state.',\n ...fields.map((key) => AI_SEO_FIELD_RULES[key]).filter((rule): rule is string => Boolean(rule)),\n // Every field a person reads is held to this, keywords or none, so it is\n // stated on every shape rather than inside the keyword rule it used to\n // ride in: `checkAiSeoFields` refuses a repeat in a listing that named\n // no keywords too.\n 'Never repeat the same word three times in one field.',\n ...(shape.keywords\n ? [\n 'A target keyword goes in only where the page is about it and it reads naturally: at most once in the title and once in the description. Never list keywords, and leave out a keyword the page is not about.',\n ]\n : []),\n ...(shape.otherTitles\n ? ['Do not reuse a title another page of the site already uses.']\n : []),\n 'Write in the language of the page text.',\n ]\n return [\n {\n text:\n 'You write the search listing for one page or one product of a website: what a search result shows.\\n\\n' +\n `Answer by calling ${AI_SEO_FIELDS_TOOL_NAME} exactly once. A reply in prose cannot be used.\\n\\n` +\n `Rules:\\n${rules.map((rule) => `- ${rule}`).join('\\n')}`,\n cacheBreakpoint: true,\n },\n ]\n}\n\n/** The rules for a listing of the default shape, for a door that names no other. */\nexport const AI_SEO_FIELDS_INSTRUCTIONS: AiSystemBlock[] = aiSeoFieldsInstructions({\n fields: ['title', 'description', 'breadcrumb'],\n keywords: false,\n otherTitles: false,\n})\n\n/**\n * What the site itself is, in the words of whoever set it up (AGL-2918).\n *\n * A page's own text says what the page is; it does not say who the page is\n * written for. On a site whose pages have just been generated that gap is at\n * its widest — every page reads like the same competent stranger wrote it —\n * so the words the owner used are carried into the listing rather than\n * inferred back out of the copy they produced.\n *\n * It rides in the user turn and nowhere else: it is one site's own words, so\n * putting it in a rules block would key the platform's cache entry on a\n * tenant's sentence, and the rules already say to write from what is given.\n */\nexport interface AiSeoSiteWords {\n /** What kind of site it is: \"a neighborhood dog groomer\". */\n about?: string | null\n /** Who it is for: \"local dog owners who want a regular groom booked online\". */\n audience?: string | null\n}\n\nexport interface AiSeoFieldsPromptInput {\n subject: { kind: 'screen' | 'product'; name: string; path?: string | null }\n /** The site's name, as its visitors see it. */\n brand: string\n /** What the site is and who it is for, where the caller knows (AGL-2918). */\n site?: AiSeoSiteWords | null\n /** What the page or the product says: a page's Markdown, a product's description. */\n text: string\n fields: readonly SeoListingFieldKey[]\n current?: AiSeoFieldValues\n /**\n * What is known about the share image, when the listing has one: the\n * description it carries, and the page text around the same picture where\n * the page shows it.\n */\n image?: { alt?: string | null; context?: string | null } | null\n keywords?: readonly string[]\n otherTitles?: readonly string[]\n}\n\n/** The longest a site's own words run in a prompt; they are a phrase, not copy. */\nconst SITE_WORDS_MAX_CHARS = 160\n\n/** One of the site's own words as its line reads, or nothing when it says nothing. */\nfunction siteWordsLine(label: string, value: string | null | undefined): string {\n const text = (value ?? '').replace(/\\s+/g, ' ').trim().slice(0, SITE_WORDS_MAX_CHARS).trim()\n return text ? `${label}: ${text}` : ''\n}\n\n/** The user turn: the subject, what it says now, and the page's own text. */\nexport function aiSeoFieldsPrompt(input: AiSeoFieldsPromptInput): string {\n const fields = orderedSeoListingFields(input.fields)\n const lines = [\n `Site: ${input.brand || 'untitled site'}`,\n siteWordsLine('What the site is', input.site?.about),\n siteWordsLine('Who the site is for', input.site?.audience),\n input.subject.kind === 'product'\n ? `Product: ${input.subject.name || 'untitled product'}`\n : `Page: ${input.subject.name || 'untitled page'}${input.subject.path ? ` (${input.subject.path})` : ''}`,\n `Fields to write: ${fields.join(', ')}`,\n // Only the two site lines can be empty here, and an empty one is a\n // question nobody answered rather than a blank line to send.\n ].filter(Boolean)\n const current = fields\n .map((key) => [key, input.current?.[key]?.trim()] as const)\n .filter(([, value]) => Boolean(value))\n if (current.length) {\n lines.push('Current listing:', ...current.map(([key, value]) => `- ${key}: ${value}`))\n }\n if (input.image) {\n const known = [\n input.image.alt && `its current description: ${input.image.alt}`,\n input.image.context && `the page text beside it: ${input.image.context}`,\n ]\n .filter(Boolean)\n .join('; ')\n lines.push(`Share image: ${known || 'set, and nothing is known about what it shows'}`)\n }\n if (input.keywords?.length) lines.push(`Target keywords: ${input.keywords.join(', ')}`)\n if (input.otherTitles?.length) {\n lines.push(\n 'Titles other pages already use:',\n ...input.otherTitles.slice(0, OTHER_TITLES_LISTED).map((title) => `- ${title}`),\n )\n }\n lines.push(\n '',\n input.subject.kind === 'product' ? 'Product description:' : 'Page text:',\n input.text.trim() || '(no text)',\n )\n return lines.join('\\n')\n}\n\nexport interface GenerateSeoFieldsInput extends Omit<AiSeoFieldsPromptInput, 'fields'> {\n /** The fields to write; the title, description and breadcrumb label when omitted. */\n fields?: readonly SeoListingFieldKey[]\n /** The step kind the call is routed as; `job.seo` when omitted. */\n step?: AiStepKind\n settings?: AiPluginSettings\n model?: string\n /**\n * The answer's ceiling: `AI_SEO_FIELDS_MAX_TOKENS` when omitted. A job step\n * passes the most its registered time allows on the model it runs\n * (AGL-3035).\n */\n maxTokens?: number\n signal?: AbortSignal\n provider?: AiProvider\n}\n\n/** What a listing is written with when the caller names no fields. */\nexport const AI_SEO_DEFAULT_FIELDS: readonly SeoListingFieldKey[] = ['title', 'description', 'breadcrumb']\n\n/**\n * Propose a search listing for a page or a product.\n *\n * STABLE: a page generator calls this for the title and description of a\n * page it builds (`fields: ['title', 'description']`), and the `seo` job\n * calls it for a page's or a product's whole listing. It asks the routing\n * table's `job.seo` model through the doctrine's generation call and resolves\n * to its result — `ok` with the values, `needs_input` when the answer could\n * not be held to the editor after one re-ask, `refused` when the model\n * declined. It writes nothing.\n */\nexport async function generateSeoFields(\n input: GenerateSeoFieldsInput,\n): Promise<AiValidatedGeneration<AiSeoFieldValues>> {\n const hasImage = Boolean(input.image)\n const fields = orderedSeoListingFields(input.fields ?? AI_SEO_DEFAULT_FIELDS).filter(\n (key) => key !== 'imageAlt' || hasImage,\n )\n const keywords = input.keywords ?? []\n const step = input.step ?? 'job.seo'\n return runValidatedGeneration('seo-fields', {\n step,\n // The row's thinking and effort (AGL-3781): a listing's ceiling holds the\n // answer alone.\n ...aiRouteSettings(step),\n ...(input.settings ? { settings: input.settings } : {}),\n ...(input.model ? { model: input.model } : {}),\n instructions: aiSeoFieldsInstructions({\n fields,\n keywords: keywords.length > 0,\n otherTitles: Boolean(input.otherTitles?.length),\n }),\n messages: [{ role: 'user', content: aiSeoFieldsPrompt({ ...input, fields, keywords }) }],\n tool: aiSeoFieldsTool(fields),\n maxTokens: input.maxTokens ?? AI_SEO_FIELDS_MAX_TOKENS,\n ...(input.signal ? { signal: input.signal } : {}),\n ...(input.provider ? { provider: input.provider } : {}),\n check: (answer) =>\n checkAiSeoFields(answer, {\n fields,\n hasImage,\n keywords,\n otherTitles: input.otherTitles,\n }),\n })\n}\n"],"names":["AI_ROUTING_TABLE","aiRouteSettings","AI_SEO_FIELDS_TOOL_NAME","aiSeoFieldsTool","checkAiSeoFields","orderedSeoListingFields","runValidatedGeneration","AI_SEO_FIELDS_MAX_TOKENS","maxTokens","OTHER_TITLES_LISTED","AI_SEO_FIELD_RULES","title","description","breadcrumb","aiSeoFieldsInstructions","shape","fields","rules","map","key","filter","rule","Boolean","keywords","otherTitles","text","join","cacheBreakpoint","AI_SEO_FIELDS_INSTRUCTIONS","SITE_WORDS_MAX_CHARS","siteWordsLine","label","value","replace","trim","slice","aiSeoFieldsPrompt","input","lines","brand","site","about","audience","subject","kind","name","path","current","length","push","image","known","alt","context","AI_SEO_DEFAULT_FIELDS","generateSeoFields","hasImage","step","settings","model","instructions","messages","role","content","tool","signal","provider","check","answer"],"mappings":"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"}
@@ -29,7 +29,7 @@ import { logAiAssistSection } from "../activity/ai-activity.js";
29
29
  // spec replaces the barrel with a closed-world factory, and nothing replaces
30
30
  // the entry point, so the spec exercises the real request shape and the real
31
31
  // error boundary rather than a stub that agrees with the handler.
32
- import { AI_ROUTING_TABLE, aiModelForStep } from "../providers/routing.js";
32
+ import { AI_ROUTING_TABLE, aiModelForStep, aiRouteSettings } from "../providers/routing.js";
33
33
  import { AiUpstreamError, aiProviderReady, runAiRequest } from "../runtime/ai-runtime.js";
34
34
  import { assistModeSystemBlocks, assistSectionTool, readAssistSection } from "./ai-assist-prompts.js";
35
35
  // By its own entry point for the same reason (AGL-2925): the per-address
@@ -396,7 +396,8 @@ const ELEMENT_MAX_TOKENS = AI_ROUTING_TABLE['copy.element'].maxTokens;
396
396
  const result = await runAiRequest(_extends({
397
397
  model,
398
398
  maxTokens,
399
- stream: false,
399
+ stream: false
400
+ }, aiRouteSettings(assistStepKindForMode(mode)), {
400
401
  // The mode's prompt with the acceptable-use rules ahead of it
401
402
  // (AGL-2925), from `ai-assist-prompts.ts` so a spec can read what
402
403
  // this route sends without standing up the route.
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../../../../../libs/plugins/ai/src/lib/server/ai-assist.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\nimport {\n checkEntitlement,\n type PluginApiHandler,\n type PluginApiResponse,\n} from '@aglyn/aglyn/server'\nimport {\n assistCreditsFromUsd,\n assistFreeTasteRefusalText,\n assistNotOnPlanText,\n assistOwnControlRefusalText,\n} from '../usage/assist-credits'\nimport { resolveEffectivePlan } from '@aglyn/aglyn/app-utils/plan-entitlements'\nimport { aiAllotmentRefusalText } from '../model/ai-allotments'\nimport { resolveAiModelChoice } from '../providers/model-choice'\nimport { aiUsageMeter } from '../usage/ai-usage-meter'\nimport {\n permissionRefusal,\n checkRateLimit,\n featureLockdownRefusal,\n firebaseAdmin,\n getOrgForUser,\n lockdownRefusal,\n memberHasPermissionOnHost,\n rateLimitHeaders,\n} from '@aglyn/tenant-data-admin'\nimport { aiOverageReservationRefusal } from '../billing/ai-overage-gate'\nimport { recordUserAiRefusal } from '../usage/ai-usage-by-user'\nimport {\n estimateAssistCostUsd,\n publicAssistQuota,\n recordAssistCost,\n releaseAssistMessage,\n reserveAssistMessage,\n type AssistReservation,\n} from '../usage/assist-usage'\nimport { logAiAssistSection } from '../activity/ai-activity'\n// By its own entry point rather than the barrel (AGL-2903): this handler's\n// spec replaces the barrel with a closed-world factory, and nothing replaces\n// the entry point, so the spec exercises the real request shape and the real\n// error boundary rather than a stub that agrees with the handler.\nimport { AI_ROUTING_TABLE, aiModelForStep } from '../providers/routing'\nimport {\n AiUpstreamError,\n aiProviderReady,\n runAiRequest,\n type AiResult,\n type AiTool,\n} from '../runtime/ai-runtime'\nimport {\n ASSIST_SECTION_TOOL_NAME,\n assistModeSystemBlocks,\n assistSectionTool,\n readAssistSection,\n type AiAssistMode,\n} from './ai-assist-prompts'\n// By its own entry point for the same reason (AGL-2925): the per-address\n// rung is real in the spec, keyed on whatever address the harness sends.\nimport { checkAiClientIpRateLimit } from '../runtime/ai-abuse-guards'\n\n/**\n * The model each mode is served by, through the routing table — tiered by\n * what the mode actually asks for (AGL-2486), not one model for all three.\n *\n * - **element** → the fast tier (`copy.element`). Rewriting one button label\n * or one heading to an instruction is a short, bounded transformation with\n * the source text supplied: the least model-shaped work on this route and\n * the highest-volume (a user clicking \"improve this\" runs it repeatedly on\n * the same element).\n * - **blog** → the balanced tier (`copy.blog`). A post body is real long-form\n * writing, and the difference between tiers is legible to the reader.\n * - **section** → the balanced tier (`copy.section`). A constrained-JSON node\n * subtree over the published-component allowlist is a structured-generation\n * task with a schema the sanitizer will reject if it is got wrong, and a\n * rejected generation costs the whole request rather than part of it.\n *\n * ⚠️ NO PROMPT CACHING IS LOST BY THE FAST RUNG. Each mode's static text now\n * carries a breakpoint (`assistModeSystemBlocks`), but none of the three is\n * long enough for its model to cache: the catalog states each model's\n * minimum and `runtime/ai-prompt-cache.spec.ts` measures every door against\n * it. That is why the tier drop is free here and NOT free on\n * `/api/assist/chat`, whose ~1,030-token prefix clears the balanced tier's\n * minimum and would stop caching entirely on the fast tier's — cheaper per\n * token, dearer per request, and invisible either way without that\n * measurement. See the header of `assist-chat.ts`.\n *\n * The cost meter prices by model id, so the value returned here MUST be the\n * one passed to `recordAssistCost` — same call, same variable. A tiering\n * change that updates the request and not the meter reports one tier's money\n * for another tier's tokens, which is the failure the by-model rate table\n * exists to stop.\n */\nexport function assistModelForMode(mode: AiAssistMode): string {\n return aiModelForStep(assistStepKindForMode(mode))\n}\n\n/** The routing table's step kind for each mode. */\nexport function assistStepKindForMode(\n mode: AiAssistMode,\n): 'copy.element' | 'copy.blog' | 'copy.section' {\n return mode === 'element' ? 'copy.element' : mode === 'blog' ? 'copy.blog' : 'copy.section'\n}\n\n/**\n * Per-uid burst limit, matched to `/api/assist/chat`'s 20/min so a client\n * cannot arbitrage the two assist doors against each other.\n */\nconst ASSIST_RATE_LIMIT = 20\nconst ASSIST_RATE_WINDOW_MS = 60_000\n\nconst SECTION_MAX_TOKENS = AI_ROUTING_TABLE['copy.section'].maxTokens\nconst BLOG_MAX_TOKENS = AI_ROUTING_TABLE['copy.blog'].maxTokens\nconst ELEMENT_MAX_TOKENS = AI_ROUTING_TABLE['copy.element'].maxTokens\n\n/**\n * What a `refusal` stop is answered with. The model declined the brief —\n * a policy verdict on the instruction, not a fault — so the sentence asks\n * for a different one rather than a retry. The tokens were spent and are\n * metered before this is sent.\n */\nconst REFUSED = {\n error: 'The AI declined that instruction — try a different one.',\n}\n\n/**\n * AI assist (AGL-89/130/169), the besigner copy assistant's door — the\n * section mode composes over the published-component allowlist and every\n * generated subtree passes the node-definition sanitizer a marketplace\n * install passes, before it reaches a canvas. `/api/ai/assist` is served by\n * the plugin API dispatcher under the `ai` prefix. Gated on a ready provider,\n * one registered with its key set (501 without, the same degrade pattern as\n * Stripe); auth via Firebase ID token.\n *\n * The provider call is `runAiRequest`, the runtime every AI door shares\n * (AGL-2903). This handler owns the ladder below and the response shapes;\n * `ai-assist-prompts.ts` owns the three prompts and the section tool; the\n * runtime owns the wire, the usage arithmetic and the error boundary. The\n * request keeps the model per mode, the `max_tokens` and the absent\n * `thinking` field it has always had — a fast-tier model rejects an explicit\n * one, and the balanced modes keep the model default.\n *\n * A section arrives through a strict tool (AGL-2937). It used to be asked\n * for as bare JSON and parsed: a parse that failed answered 502 with the\n * tokens already spent, so the whole request bought nothing and the member's\n * next move was to spend it again. The text parse is kept behind the tool,\n * for a provider whose adapter has none.\n *\n * ── The spend ladder (AGL-2073) ────────────────────────────────────────────\n *\n * This route reached the provider behind nothing but an entitlement check. Free\n * orgs could not get in, so the free tier was safe — but for any Pro org it\n * had no rate limit, no quota, no per-org cost telemetry, and it resolved the\n * org from the USER rather than the request, so someone with one paid org got\n * assist charged against whichever org `getOrgForUser` happened to return. One\n * subscription, unbounded and unmeasured provider spend.\n *\n * The ladder now, in order, each rung able to go red on its own:\n * 405 → 501 no ready provider → 401 no token → 400 bad body (the request\n * must NAME the org it is metered against) → 403 not a member of that org →\n * 423 the workspace's AI pause → 403 the member's role lacks `ai.use`, or\n * `ai.generate` for a section (AGL-2927; staff pass) → 403 no `aiAssist`\n * entitlement → 423 org lockdown → 429 rate limit → 429 quota → the model\n * call → meter.\n *\n * Two deliberate choices about which way each rung fails:\n *\n * - **The rate limiter fails SOFT and is not the spend bound.** It is a\n * per-instance in-memory window, so N serverless instances admit N × 20/min;\n * it smooths bursts and nothing more. Treating it as the cap would be the\n * fail-open this issue is about.\n * - **The reservation fails CLOSED.** It is the only global, atomic bound on\n * provider spend, so a reservation that cannot be taken — a Firestore\n * outage, a contended transaction — refuses the request with 503 rather than\n * calling the provider uncapped. An unavailable meter must stop the spending,\n * not wave it through.\n *\n * The reservation is `reserveAssistMessage` — the same shared counter the\n * console chat route moves (AGL-2057), not a second mechanism. That is what\n * closes the \"uncapped for paid\" hole the earlier audit named: an entitled org\n * carries the monthly runaway guard, and because both doors move the SAME\n * counter, an org's monthly assist budget is shared across them rather than\n * doubled by having two.\n *\n * Metering is `recordAssistCost`, the signal + rollup half of the assist\n * meters WITHOUT the verbatim half: what a customer types here is their own\n * site copy and blog bodies, and retaining that for 180 days is a data flow\n * the published privacy disclosure does not describe. The margin question is\n * answered by the signal alone.\n */\nexport const aiAssistHandler: PluginApiHandler = async (req, res) => {\n if (req.method !== 'POST') {\n return res.status(405).json({ error: 'Method not allowed' })\n }\n if (!aiProviderReady()) {\n return res\n .status(501)\n .json({ error: 'AI assist is not configured (no AI provider key).' })\n }\n\n const headers = req.headers as Partial<Record<string, string>>\n const authorization = headers.authorization ?? ''\n const idToken = authorization.startsWith('Bearer ')\n ? authorization.slice('Bearer '.length)\n : undefined\n if (!idToken) return res.status(401).json({ error: 'Unauthenticated' })\n\n const body = req.body ?? {}\n const text = String(body?.text ?? '').slice(0, 12000)\n const instruction = String(body?.instruction ?? '').slice(0, 500)\n // Modes (AGL-130): 'element' rewrites short copy; 'blog' writes or\n // improves markdown-lite entry bodies with title/excerpt context.\n const mode =\n body?.mode === 'blog'\n ? 'blog'\n : body?.mode === 'section'\n ? 'section'\n : 'element'\n const title = String(body?.title ?? '').slice(0, 200)\n const excerpt = String(body?.excerpt ?? '').slice(0, 500)\n // The request must NAME the org it is about to be metered against\n // (AGL-2073, the AGL-1934 rule applied to this door). Resolving the org\n // from the user alone charged a multi-org user's assist to whichever org\n // came back first — and handed their free workspaces a paid feature.\n const orgId = String(body?.orgId ?? '').trim()\n const hostId = String(body?.hostId ?? '')\n .trim()\n .slice(0, 128)\n if (!instruction) {\n return res.status(400).json({ error: 'Missing instruction' })\n }\n if (!orgId) {\n return res\n .status(400)\n .json({ error: 'Open a workspace before using AI assist' })\n }\n\n let reservation: AssistReservation | null = null\n let providerAnswered = false\n let firestore: FirebaseFirestore.Firestore | null = null\n\n try {\n const app = firebaseAdmin.app()\n firestore = app.firestore()\n const decoded = await app.auth().verifyIdToken(idToken)\n\n // Plan gate (AGL-469): AI assist is a Pro+ entitlement with real token\n // cost — resolve the caller's org and check it server-side; a plan-less\n // org resolves as `free` and is denied. Scoped to the NAMED org, so\n // membership of some other paid org is not a key to this one.\n const resolved = await getOrgForUser(decoded.uid, orgId)\n if (!resolved || resolved.orgId !== orgId) {\n return res\n .status(403)\n .json({ error: 'You are not a member of that organization' })\n }\n const org = resolved.org ?? {}\n\n // Permission (AGL-2927), directly after membership. The element and\n // blog modes are assistance and sell under `ai.use`; a generated\n // section is a generation and sells under `ai.generate`. Decided on the\n // caller's own axis — the org catalog for an org-wide member, the site\n // the body named for a collaborator, who is refused when it named none.\n // A fact about the caller, so it is answered before the plan is. Staff\n // pass, as at every other org route. Beside it, the workspace-scoped\n // pause on `ai-assist`: the dispatcher already applied the platform-wide\n // switch by path, before it knew which org the body would name.\n const staff = decoded['staff'] === true\n const aiPaused = await featureLockdownRefusal({\n feature: 'ai-assist',\n staff,\n orgId,\n })\n if (aiPaused) return forwardRefusal(res, aiPaused)\n const permission = mode === 'section' ? 'ai.generate' : 'ai.use'\n if (\n !staff &&\n !(await memberHasPermissionOnHost(orgId, hostId, resolved.member, permission))\n ) {\n return forwardRefusal(res, permissionRefusal(permission))\n }\n\n const entitled = checkEntitlement(org, 'aiAssist')\n if (!entitled) {\n return res.status(403).json({ error: assistNotOnPlanText() })\n }\n\n // Org-scoped lockdown. The dispatcher already evaluates platform and user\n // scope plus the `ai-assist` feature kill switch, but its org scope needs\n // a `hostId` this route's callers do not send — so a suspended workspace\n // kept spending. The org doc is already in hand, so the verdict is free.\n const locked = await lockdownRefusal({\n request: { method: req.method },\n staff,\n uid: decoded.uid,\n org: org as Record<string, unknown>,\n })\n if (locked) return forwardRefusal(res, locked)\n\n const rate = checkRateLimit(`ai-assist:${decoded.uid}`, {\n limit: ASSIST_RATE_LIMIT,\n windowMs: ASSIST_RATE_WINDOW_MS,\n })\n if (!rate.allowed) {\n for (const [name, value] of Object.entries(rateLimitHeaders(rate))) {\n res.setHeader(name, value)\n }\n return res\n .status(429)\n .json({ error: 'Too many AI requests — slow down a moment' })\n }\n // The same window keyed on the trusted-hop client address (AGL-2925):\n // accounts rotated behind one address share one budget.\n const ipRate = checkAiClientIpRateLimit(headers)\n if (ipRate && !ipRate.allowed) {\n for (const [name, value] of Object.entries(rateLimitHeaders(ipRate))) {\n res.setHeader(name, value)\n }\n return res\n .status(429)\n .json({ error: 'Too many AI requests — slow down a moment' })\n }\n\n // RESERVED, not merely checked (AGL-2057/2073). Read-and-increment in one\n // transaction, BEFORE a single token is spent, so N concurrent requests\n // cannot all read the same \"under the cap\" and a client that drops the\n // connection has already been counted.\n try {\n // `org` is the document `entitled` came from, and it is what makes\n // the plan's own assist band bind rather than the operator backstop\n // alone. Generative building is the dearest thing this route serves,\n // so the band it spends against has to be the one that was sold.\n reservation = await reserveAssistMessage(\n firestore,\n orgId,\n entitled,\n new Date(),\n org,\n // The allotments that apply (AGL-2942): the asker's own, theirs on\n // the canvas's site, and the site's.\n { uid: decoded.uid, hostId: hostId || null },\n )\n } catch (error) {\n // FAIL CLOSED. The reservation is the only global bound on what this\n // route can spend at the provider; if it cannot be taken there is no cap,\n // and no cap is worse than no answer.\n console.error('assist reservation failed', error)\n return res\n .status(503)\n .json({ error: 'AI assist is temporarily unavailable' })\n }\n // A refusal is the asker's as well as the workspace's (AGL-2928): their\n // month counts it beside the org counter the reservation moved.\n recordUserAiRefusal(firestore, orgId, decoded.uid, reservation)\n if (!reservation.allowed) {\n const refusedBy = reservation.refusedBy\n // A hard allotment (AGL-2942): the monthly line a manager drew for\n // the asker or the site, with the sentence naming who can raise it.\n if (refusedBy === 'allotment') {\n return res.status(429).json({\n error: aiAllotmentRefusalText(reservation.allotment?.refusal?.scope),\n reason: 'quota',\n quota: publicAssistQuota(reservation),\n meter: aiUsageMeter(reservation),\n })\n }\n // The org's own controls are a 402, not a 429: credits past the band\n // are for sale and this workspace either switched the sale off\n // (AGL-2653) or capped what it would buy (AGL-2898), so the sentence\n // names the control that refused — the same sentence the console\n // assistant gives, from the same helper. Every other refusal keeps\n // the 429.\n // AGLYN'S OWN OVERAGE GUARDS (AGL-3011): a card, a pause, this\n // month's ceiling or a settling balance, each with the status that\n // says whether the workspace can act on it. Before the workspace's\n // own controls, which answer only for the controls it set itself.\n const overage = aiOverageReservationRefusal(reservation)\n if (overage) {\n return res.status(overage.status).json({\n error: overage.text,\n reason: 'quota',\n quota: publicAssistQuota(reservation),\n meter: aiUsageMeter(reservation),\n })\n }\n const ownControl = assistOwnControlRefusalText(org, refusedBy)\n return res.status(ownControl ? 402 : 429).json({\n error:\n ownControl ??\n // The Free taste's own precautions (AGL-2925), in the sentences\n // the console assistant uses.\n assistFreeTasteRefusalText(refusedBy) ??\n 'This workspace reached its AI assist limit for the month — contact support if you need a higher cap',\n // CREDITS, never the reservation itself — see `publicAssistQuota`.\n quota: publicAssistQuota(reservation),\n meter: aiUsageMeter(reservation),\n })\n }\n\n const tier: 'free' | 'entitled' = entitled ? 'entitled' : 'free'\n // ONE resolution, read by both the request and the meter — see\n // `assistModelForMode`. Two call sites would let them drift. A model the\n // asker picked (AGL-2942) replaces the routing table's answer only when\n // the plan, the org's restriction and the allotment allowlists all allow\n // it; anything else runs on Auto.\n const held = reservation\n const choice = resolveAiModelChoice(assistStepKindForMode(mode), body?.model, {\n plan: resolveEffectivePlan(org),\n allotmentModels: held.allotment?.models ?? null,\n orgModels: held.allotment?.orgModels ?? null,\n })\n const model = choice?.model ?? assistModelForMode(mode)\n /** The usage strip's envelope for an answered request (AGL-2942). */\n const meterFor = (result: AiResult) =>\n aiUsageMeter(held, {\n lastCredits: assistCreditsFromUsd(estimateAssistCostUsd(result.usage, model)),\n model: { id: model, auto: choice?.auto ?? true },\n })\n /** Meter what the provider actually charged us for, per org. */\n // The account the reservation drew on (AGL-2925), read once here: the\n // closure below runs after `reservation` may have been cleared by a\n // refund, and a refund never reaches the meter.\n const free = reservation?.free ?? null\n const meter = (result: AiResult): Promise<unknown> =>\n recordAssistCost(firestore, orgId, {\n route: `/api/ai/assist/${mode}`,\n hostId: hostId || null,\n model,\n tier,\n usage: result.usage,\n docsPaths: [],\n stopReason: result.stopReason,\n free,\n }).catch((error) => console.error('assist cost metering failed', error))\n\n /**\n * One provider call, with the refund decided at the boundary: a refused\n * request (a non-2xx answer) spent nothing, so the reservation goes back\n * — against the keys it RECORDED, never against \"now\" — and the caller\n * gets the runtime's fixed sentence, never the provider's (AGL-2815).\n * A result, even a refusal, means the tokens ARE spent: every exit after\n * it meters and none refunds. Anything else thrown never reached the\n * provider and is left to the outer catch, which refunds too.\n */\n const ask = async (\n content: string,\n maxTokens: number,\n tools?: AiTool[],\n ): Promise<AiResult | null> => {\n try {\n const result = await runAiRequest({\n model,\n maxTokens,\n stream: false,\n // The mode's prompt with the acceptable-use rules ahead of it\n // (AGL-2925), from `ai-assist-prompts.ts` so a spec can read what\n // this route sends without standing up the route.\n system: assistModeSystemBlocks(mode),\n messages: [{ role: 'user', content }],\n ...(tools ? { tools } : {}),\n })\n providerAnswered = true\n await meter(result)\n return result\n } catch (error) {\n if (!(error instanceof AiUpstreamError)) throw error\n providerAnswered = true\n await releaseReservation(firestore, orgId, reservation as AssistReservation)\n reservation = null\n res.status(502).json({ error: error.message })\n return null\n }\n }\n\n // Generate section (AGL-169): a constrained-JSON node subtree over the\n // marketplace component allowlist; the response passes the same\n // sanitizer as marketplace installs before it ever reaches a canvas.\n if (mode === 'section') {\n const result = await ask(\n `Section to design: ${instruction}`,\n SECTION_MAX_TOKENS,\n [assistSectionTool()],\n )\n if (!result) return\n if (result.kind === 'refusal') {\n return res.status(502).json({ ...REFUSED, meter: meterFor(result) })\n }\n const read = readAssistSection(result)\n if (read.status !== 'ok') {\n // Unreadable is the provider's fault and rejected is the answer's,\n // the two statuses this route has always drawn that line between.\n return res.status(read.status === 'unreadable' ? 502 : 422).json({ error: read.error })\n }\n // A subtree is going back (AGL-2929): the act, its size and its site in\n // the customer's feed — never the instruction or the copy generated.\n await logAiAssistSection(\n { uid: decoded.uid, email: decoded.email ?? null },\n {\n orgId,\n hostId: hostId || null,\n nodeCount: Object.keys(read.section.nodes).length,\n },\n )\n return res.status(200).json({ section: read.section, meter: meterFor(result) })\n }\n\n const result = await ask(\n mode === 'blog'\n ? [\n title && `Title: ${title}`,\n excerpt && `Excerpt: ${excerpt}`,\n text && `Current body:\\n${text}`,\n `Instruction: ${instruction}`,\n ]\n .filter(Boolean)\n .join('\\n\\n')\n : text\n ? `Current element text:\\n${text}\\n\\nInstruction: ${instruction}`\n : `Write website element text. Instruction: ${instruction}`,\n mode === 'blog' ? BLOG_MAX_TOKENS : ELEMENT_MAX_TOKENS,\n )\n if (!result) return\n if (result.kind === 'refusal') {\n return res.status(502).json({ ...REFUSED, meter: meterFor(result) })\n }\n const output = result.text.trim()\n if (!output) {\n return res.status(502).json({ error: 'Empty AI response', meter: meterFor(result) })\n }\n return res.status(200).json({ text: output, meter: meterFor(result) })\n } catch (error) {\n console.error(error)\n // The provider was never reached — a token verification failure, a DNS\n // error, a socket reset before any response. Nothing was spent, so the\n // reservation must not be. If the provider DID answer, the tokens are\n // real and the reservation stays consumed however the request ends.\n if (firestore && reservation && !providerAnswered) {\n await releaseReservation(firestore, orgId, reservation)\n }\n return res.status(500).json({ error: 'Assist failed' })\n }\n}\n\n/**\n * Hand a reservation back, never letting the refund itself fail the request.\n * Released against the keys the RESERVATION recorded — a reservation taken at\n * 23:59:59 and released at 00:00:01 must not credit the next day.\n */\nasync function releaseReservation(\n firestore: FirebaseFirestore.Firestore,\n orgId: string,\n reservation: AssistReservation,\n): Promise<void> {\n await releaseAssistMessage(firestore, orgId, reservation).catch((error) =>\n console.error('assist reservation release failed', error),\n )\n}\n\n/** Re-emit a lockdown `Response` through the plugin API's res object. */\nasync function forwardRefusal(\n res: PluginApiResponse,\n refusal: Response,\n): Promise<void> {\n const retryAfter = refusal.headers.get('Retry-After')\n if (retryAfter) res.setHeader('Retry-After', retryAfter)\n const body = await refusal.json().catch(() => ({ error: 'locked' }))\n res.status(refusal.status).json(body)\n}\n"],"names":["checkEntitlement","assistCreditsFromUsd","assistFreeTasteRefusalText","assistNotOnPlanText","assistOwnControlRefusalText","resolveEffectivePlan","aiAllotmentRefusalText","resolveAiModelChoice","aiUsageMeter","permissionRefusal","checkRateLimit","featureLockdownRefusal","firebaseAdmin","getOrgForUser","lockdownRefusal","memberHasPermissionOnHost","rateLimitHeaders","aiOverageReservationRefusal","recordUserAiRefusal","estimateAssistCostUsd","publicAssistQuota","recordAssistCost","releaseAssistMessage","reserveAssistMessage","logAiAssistSection","AI_ROUTING_TABLE","aiModelForStep","AiUpstreamError","aiProviderReady","runAiRequest","assistModeSystemBlocks","assistSectionTool","readAssistSection","checkAiClientIpRateLimit","assistModelForMode","mode","assistStepKindForMode","ASSIST_RATE_LIMIT","ASSIST_RATE_WINDOW_MS","SECTION_MAX_TOKENS","maxTokens","BLOG_MAX_TOKENS","ELEMENT_MAX_TOKENS","REFUSED","error","aiAssistHandler","req","res","headers","method","status","json","authorization","idToken","startsWith","slice","length","undefined","body","text","String","instruction","title","excerpt","orgId","trim","hostId","reservation","providerAnswered","firestore","resolved","held","app","decoded","auth","verifyIdToken","uid","org","staff","aiPaused","feature","forwardRefusal","permission","member","entitled","locked","request","rate","limit","windowMs","allowed","name","value","Object","entries","setHeader","ipRate","Date","console","ownControl","refusedBy","allotment","refusal","scope","reason","quota","meter","overage","tier","choice","model","plan","allotmentModels","models","orgModels","meterFor","result","lastCredits","usage","id","auto","free","route","docsPaths","stopReason","catch","ask","content","tools","stream","system","messages","role","releaseReservation","message","kind","read","email","nodeCount","keys","section","nodes","filter","Boolean","join","output","retryAfter","get"],"mappings":"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"}
1
+ {"version":3,"sources":["../../../../../../../libs/plugins/ai/src/lib/server/ai-assist.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\nimport {\n checkEntitlement,\n type PluginApiHandler,\n type PluginApiResponse,\n} from '@aglyn/aglyn/server'\nimport {\n assistCreditsFromUsd,\n assistFreeTasteRefusalText,\n assistNotOnPlanText,\n assistOwnControlRefusalText,\n} from '../usage/assist-credits'\nimport { resolveEffectivePlan } from '@aglyn/aglyn/app-utils/plan-entitlements'\nimport { aiAllotmentRefusalText } from '../model/ai-allotments'\nimport { resolveAiModelChoice } from '../providers/model-choice'\nimport { aiUsageMeter } from '../usage/ai-usage-meter'\nimport {\n permissionRefusal,\n checkRateLimit,\n featureLockdownRefusal,\n firebaseAdmin,\n getOrgForUser,\n lockdownRefusal,\n memberHasPermissionOnHost,\n rateLimitHeaders,\n} from '@aglyn/tenant-data-admin'\nimport { aiOverageReservationRefusal } from '../billing/ai-overage-gate'\nimport { recordUserAiRefusal } from '../usage/ai-usage-by-user'\nimport {\n estimateAssistCostUsd,\n publicAssistQuota,\n recordAssistCost,\n releaseAssistMessage,\n reserveAssistMessage,\n type AssistReservation,\n} from '../usage/assist-usage'\nimport { logAiAssistSection } from '../activity/ai-activity'\n// By its own entry point rather than the barrel (AGL-2903): this handler's\n// spec replaces the barrel with a closed-world factory, and nothing replaces\n// the entry point, so the spec exercises the real request shape and the real\n// error boundary rather than a stub that agrees with the handler.\nimport { AI_ROUTING_TABLE, aiModelForStep, aiRouteSettings } from '../providers/routing'\nimport {\n AiUpstreamError,\n aiProviderReady,\n runAiRequest,\n type AiResult,\n type AiTool,\n} from '../runtime/ai-runtime'\nimport {\n ASSIST_SECTION_TOOL_NAME,\n assistModeSystemBlocks,\n assistSectionTool,\n readAssistSection,\n type AiAssistMode,\n} from './ai-assist-prompts'\n// By its own entry point for the same reason (AGL-2925): the per-address\n// rung is real in the spec, keyed on whatever address the harness sends.\nimport { checkAiClientIpRateLimit } from '../runtime/ai-abuse-guards'\n\n/**\n * The model each mode is served by, through the routing table — tiered by\n * what the mode actually asks for (AGL-2486), not one model for all three.\n *\n * - **element** → the fast tier (`copy.element`). Rewriting one button label\n * or one heading to an instruction is a short, bounded transformation with\n * the source text supplied: the least model-shaped work on this route and\n * the highest-volume (a user clicking \"improve this\" runs it repeatedly on\n * the same element).\n * - **blog** → the balanced tier (`copy.blog`). A post body is real long-form\n * writing, and the difference between tiers is legible to the reader.\n * - **section** → the balanced tier (`copy.section`). A constrained-JSON node\n * subtree over the published-component allowlist is a structured-generation\n * task with a schema the sanitizer will reject if it is got wrong, and a\n * rejected generation costs the whole request rather than part of it.\n *\n * ⚠️ NO PROMPT CACHING IS LOST BY THE FAST RUNG. Each mode's static text now\n * carries a breakpoint (`assistModeSystemBlocks`), but none of the three is\n * long enough for its model to cache: the catalog states each model's\n * minimum and `runtime/ai-prompt-cache.spec.ts` measures every door against\n * it. That is why the tier drop is free here and NOT free on\n * `/api/assist/chat`, whose ~1,030-token prefix clears the balanced tier's\n * minimum and would stop caching entirely on the fast tier's — cheaper per\n * token, dearer per request, and invisible either way without that\n * measurement. See the header of `assist-chat.ts`.\n *\n * The cost meter prices by model id, so the value returned here MUST be the\n * one passed to `recordAssistCost` — same call, same variable. A tiering\n * change that updates the request and not the meter reports one tier's money\n * for another tier's tokens, which is the failure the by-model rate table\n * exists to stop.\n */\nexport function assistModelForMode(mode: AiAssistMode): string {\n return aiModelForStep(assistStepKindForMode(mode))\n}\n\n/** The routing table's step kind for each mode. */\nexport function assistStepKindForMode(\n mode: AiAssistMode,\n): 'copy.element' | 'copy.blog' | 'copy.section' {\n return mode === 'element' ? 'copy.element' : mode === 'blog' ? 'copy.blog' : 'copy.section'\n}\n\n/**\n * Per-uid burst limit, matched to `/api/assist/chat`'s 20/min so a client\n * cannot arbitrage the two assist doors against each other.\n */\nconst ASSIST_RATE_LIMIT = 20\nconst ASSIST_RATE_WINDOW_MS = 60_000\n\nconst SECTION_MAX_TOKENS = AI_ROUTING_TABLE['copy.section'].maxTokens\nconst BLOG_MAX_TOKENS = AI_ROUTING_TABLE['copy.blog'].maxTokens\nconst ELEMENT_MAX_TOKENS = AI_ROUTING_TABLE['copy.element'].maxTokens\n\n/**\n * What a `refusal` stop is answered with. The model declined the brief —\n * a policy verdict on the instruction, not a fault — so the sentence asks\n * for a different one rather than a retry. The tokens were spent and are\n * metered before this is sent.\n */\nconst REFUSED = {\n error: 'The AI declined that instruction — try a different one.',\n}\n\n/**\n * AI assist (AGL-89/130/169), the besigner copy assistant's door — the\n * section mode composes over the published-component allowlist and every\n * generated subtree passes the node-definition sanitizer a marketplace\n * install passes, before it reaches a canvas. `/api/ai/assist` is served by\n * the plugin API dispatcher under the `ai` prefix. Gated on a ready provider,\n * one registered with its key set (501 without, the same degrade pattern as\n * Stripe); auth via Firebase ID token.\n *\n * The provider call is `runAiRequest`, the runtime every AI door shares\n * (AGL-2903). This handler owns the ladder below and the response shapes;\n * `ai-assist-prompts.ts` owns the three prompts and the section tool; the\n * runtime owns the wire, the usage arithmetic and the error boundary. The\n * request keeps the model per mode, the `max_tokens` and the absent\n * `thinking` field it has always had — a fast-tier model rejects an explicit\n * one, and the balanced modes keep the model default.\n *\n * A section arrives through a strict tool (AGL-2937). It used to be asked\n * for as bare JSON and parsed: a parse that failed answered 502 with the\n * tokens already spent, so the whole request bought nothing and the member's\n * next move was to spend it again. The text parse is kept behind the tool,\n * for a provider whose adapter has none.\n *\n * ── The spend ladder (AGL-2073) ────────────────────────────────────────────\n *\n * This route reached the provider behind nothing but an entitlement check. Free\n * orgs could not get in, so the free tier was safe — but for any Pro org it\n * had no rate limit, no quota, no per-org cost telemetry, and it resolved the\n * org from the USER rather than the request, so someone with one paid org got\n * assist charged against whichever org `getOrgForUser` happened to return. One\n * subscription, unbounded and unmeasured provider spend.\n *\n * The ladder now, in order, each rung able to go red on its own:\n * 405 → 501 no ready provider → 401 no token → 400 bad body (the request\n * must NAME the org it is metered against) → 403 not a member of that org →\n * 423 the workspace's AI pause → 403 the member's role lacks `ai.use`, or\n * `ai.generate` for a section (AGL-2927; staff pass) → 403 no `aiAssist`\n * entitlement → 423 org lockdown → 429 rate limit → 429 quota → the model\n * call → meter.\n *\n * Two deliberate choices about which way each rung fails:\n *\n * - **The rate limiter fails SOFT and is not the spend bound.** It is a\n * per-instance in-memory window, so N serverless instances admit N × 20/min;\n * it smooths bursts and nothing more. Treating it as the cap would be the\n * fail-open this issue is about.\n * - **The reservation fails CLOSED.** It is the only global, atomic bound on\n * provider spend, so a reservation that cannot be taken — a Firestore\n * outage, a contended transaction — refuses the request with 503 rather than\n * calling the provider uncapped. An unavailable meter must stop the spending,\n * not wave it through.\n *\n * The reservation is `reserveAssistMessage` — the same shared counter the\n * console chat route moves (AGL-2057), not a second mechanism. That is what\n * closes the \"uncapped for paid\" hole the earlier audit named: an entitled org\n * carries the monthly runaway guard, and because both doors move the SAME\n * counter, an org's monthly assist budget is shared across them rather than\n * doubled by having two.\n *\n * Metering is `recordAssistCost`, the signal + rollup half of the assist\n * meters WITHOUT the verbatim half: what a customer types here is their own\n * site copy and blog bodies, and retaining that for 180 days is a data flow\n * the published privacy disclosure does not describe. The margin question is\n * answered by the signal alone.\n */\nexport const aiAssistHandler: PluginApiHandler = async (req, res) => {\n if (req.method !== 'POST') {\n return res.status(405).json({ error: 'Method not allowed' })\n }\n if (!aiProviderReady()) {\n return res\n .status(501)\n .json({ error: 'AI assist is not configured (no AI provider key).' })\n }\n\n const headers = req.headers as Partial<Record<string, string>>\n const authorization = headers.authorization ?? ''\n const idToken = authorization.startsWith('Bearer ')\n ? authorization.slice('Bearer '.length)\n : undefined\n if (!idToken) return res.status(401).json({ error: 'Unauthenticated' })\n\n const body = req.body ?? {}\n const text = String(body?.text ?? '').slice(0, 12000)\n const instruction = String(body?.instruction ?? '').slice(0, 500)\n // Modes (AGL-130): 'element' rewrites short copy; 'blog' writes or\n // improves markdown-lite entry bodies with title/excerpt context.\n const mode =\n body?.mode === 'blog'\n ? 'blog'\n : body?.mode === 'section'\n ? 'section'\n : 'element'\n const title = String(body?.title ?? '').slice(0, 200)\n const excerpt = String(body?.excerpt ?? '').slice(0, 500)\n // The request must NAME the org it is about to be metered against\n // (AGL-2073, the AGL-1934 rule applied to this door). Resolving the org\n // from the user alone charged a multi-org user's assist to whichever org\n // came back first — and handed their free workspaces a paid feature.\n const orgId = String(body?.orgId ?? '').trim()\n const hostId = String(body?.hostId ?? '')\n .trim()\n .slice(0, 128)\n if (!instruction) {\n return res.status(400).json({ error: 'Missing instruction' })\n }\n if (!orgId) {\n return res\n .status(400)\n .json({ error: 'Open a workspace before using AI assist' })\n }\n\n let reservation: AssistReservation | null = null\n let providerAnswered = false\n let firestore: FirebaseFirestore.Firestore | null = null\n\n try {\n const app = firebaseAdmin.app()\n firestore = app.firestore()\n const decoded = await app.auth().verifyIdToken(idToken)\n\n // Plan gate (AGL-469): AI assist is a Pro+ entitlement with real token\n // cost — resolve the caller's org and check it server-side; a plan-less\n // org resolves as `free` and is denied. Scoped to the NAMED org, so\n // membership of some other paid org is not a key to this one.\n const resolved = await getOrgForUser(decoded.uid, orgId)\n if (!resolved || resolved.orgId !== orgId) {\n return res\n .status(403)\n .json({ error: 'You are not a member of that organization' })\n }\n const org = resolved.org ?? {}\n\n // Permission (AGL-2927), directly after membership. The element and\n // blog modes are assistance and sell under `ai.use`; a generated\n // section is a generation and sells under `ai.generate`. Decided on the\n // caller's own axis — the org catalog for an org-wide member, the site\n // the body named for a collaborator, who is refused when it named none.\n // A fact about the caller, so it is answered before the plan is. Staff\n // pass, as at every other org route. Beside it, the workspace-scoped\n // pause on `ai-assist`: the dispatcher already applied the platform-wide\n // switch by path, before it knew which org the body would name.\n const staff = decoded['staff'] === true\n const aiPaused = await featureLockdownRefusal({\n feature: 'ai-assist',\n staff,\n orgId,\n })\n if (aiPaused) return forwardRefusal(res, aiPaused)\n const permission = mode === 'section' ? 'ai.generate' : 'ai.use'\n if (\n !staff &&\n !(await memberHasPermissionOnHost(orgId, hostId, resolved.member, permission))\n ) {\n return forwardRefusal(res, permissionRefusal(permission))\n }\n\n const entitled = checkEntitlement(org, 'aiAssist')\n if (!entitled) {\n return res.status(403).json({ error: assistNotOnPlanText() })\n }\n\n // Org-scoped lockdown. The dispatcher already evaluates platform and user\n // scope plus the `ai-assist` feature kill switch, but its org scope needs\n // a `hostId` this route's callers do not send — so a suspended workspace\n // kept spending. The org doc is already in hand, so the verdict is free.\n const locked = await lockdownRefusal({\n request: { method: req.method },\n staff,\n uid: decoded.uid,\n org: org as Record<string, unknown>,\n })\n if (locked) return forwardRefusal(res, locked)\n\n const rate = checkRateLimit(`ai-assist:${decoded.uid}`, {\n limit: ASSIST_RATE_LIMIT,\n windowMs: ASSIST_RATE_WINDOW_MS,\n })\n if (!rate.allowed) {\n for (const [name, value] of Object.entries(rateLimitHeaders(rate))) {\n res.setHeader(name, value)\n }\n return res\n .status(429)\n .json({ error: 'Too many AI requests — slow down a moment' })\n }\n // The same window keyed on the trusted-hop client address (AGL-2925):\n // accounts rotated behind one address share one budget.\n const ipRate = checkAiClientIpRateLimit(headers)\n if (ipRate && !ipRate.allowed) {\n for (const [name, value] of Object.entries(rateLimitHeaders(ipRate))) {\n res.setHeader(name, value)\n }\n return res\n .status(429)\n .json({ error: 'Too many AI requests — slow down a moment' })\n }\n\n // RESERVED, not merely checked (AGL-2057/2073). Read-and-increment in one\n // transaction, BEFORE a single token is spent, so N concurrent requests\n // cannot all read the same \"under the cap\" and a client that drops the\n // connection has already been counted.\n try {\n // `org` is the document `entitled` came from, and it is what makes\n // the plan's own assist band bind rather than the operator backstop\n // alone. Generative building is the dearest thing this route serves,\n // so the band it spends against has to be the one that was sold.\n reservation = await reserveAssistMessage(\n firestore,\n orgId,\n entitled,\n new Date(),\n org,\n // The allotments that apply (AGL-2942): the asker's own, theirs on\n // the canvas's site, and the site's.\n { uid: decoded.uid, hostId: hostId || null },\n )\n } catch (error) {\n // FAIL CLOSED. The reservation is the only global bound on what this\n // route can spend at the provider; if it cannot be taken there is no cap,\n // and no cap is worse than no answer.\n console.error('assist reservation failed', error)\n return res\n .status(503)\n .json({ error: 'AI assist is temporarily unavailable' })\n }\n // A refusal is the asker's as well as the workspace's (AGL-2928): their\n // month counts it beside the org counter the reservation moved.\n recordUserAiRefusal(firestore, orgId, decoded.uid, reservation)\n if (!reservation.allowed) {\n const refusedBy = reservation.refusedBy\n // A hard allotment (AGL-2942): the monthly line a manager drew for\n // the asker or the site, with the sentence naming who can raise it.\n if (refusedBy === 'allotment') {\n return res.status(429).json({\n error: aiAllotmentRefusalText(reservation.allotment?.refusal?.scope),\n reason: 'quota',\n quota: publicAssistQuota(reservation),\n meter: aiUsageMeter(reservation),\n })\n }\n // The org's own controls are a 402, not a 429: credits past the band\n // are for sale and this workspace either switched the sale off\n // (AGL-2653) or capped what it would buy (AGL-2898), so the sentence\n // names the control that refused — the same sentence the console\n // assistant gives, from the same helper. Every other refusal keeps\n // the 429.\n // AGLYN'S OWN OVERAGE GUARDS (AGL-3011): a card, a pause, this\n // month's ceiling or a settling balance, each with the status that\n // says whether the workspace can act on it. Before the workspace's\n // own controls, which answer only for the controls it set itself.\n const overage = aiOverageReservationRefusal(reservation)\n if (overage) {\n return res.status(overage.status).json({\n error: overage.text,\n reason: 'quota',\n quota: publicAssistQuota(reservation),\n meter: aiUsageMeter(reservation),\n })\n }\n const ownControl = assistOwnControlRefusalText(org, refusedBy)\n return res.status(ownControl ? 402 : 429).json({\n error:\n ownControl ??\n // The Free taste's own precautions (AGL-2925), in the sentences\n // the console assistant uses.\n assistFreeTasteRefusalText(refusedBy) ??\n 'This workspace reached its AI assist limit for the month — contact support if you need a higher cap',\n // CREDITS, never the reservation itself — see `publicAssistQuota`.\n quota: publicAssistQuota(reservation),\n meter: aiUsageMeter(reservation),\n })\n }\n\n const tier: 'free' | 'entitled' = entitled ? 'entitled' : 'free'\n // ONE resolution, read by both the request and the meter — see\n // `assistModelForMode`. Two call sites would let them drift. A model the\n // asker picked (AGL-2942) replaces the routing table's answer only when\n // the plan, the org's restriction and the allotment allowlists all allow\n // it; anything else runs on Auto.\n const held = reservation\n const choice = resolveAiModelChoice(assistStepKindForMode(mode), body?.model, {\n plan: resolveEffectivePlan(org),\n allotmentModels: held.allotment?.models ?? null,\n orgModels: held.allotment?.orgModels ?? null,\n })\n const model = choice?.model ?? assistModelForMode(mode)\n /** The usage strip's envelope for an answered request (AGL-2942). */\n const meterFor = (result: AiResult) =>\n aiUsageMeter(held, {\n lastCredits: assistCreditsFromUsd(estimateAssistCostUsd(result.usage, model)),\n model: { id: model, auto: choice?.auto ?? true },\n })\n /** Meter what the provider actually charged us for, per org. */\n // The account the reservation drew on (AGL-2925), read once here: the\n // closure below runs after `reservation` may have been cleared by a\n // refund, and a refund never reaches the meter.\n const free = reservation?.free ?? null\n const meter = (result: AiResult): Promise<unknown> =>\n recordAssistCost(firestore, orgId, {\n route: `/api/ai/assist/${mode}`,\n hostId: hostId || null,\n model,\n tier,\n usage: result.usage,\n docsPaths: [],\n stopReason: result.stopReason,\n free,\n }).catch((error) => console.error('assist cost metering failed', error))\n\n /**\n * One provider call, with the refund decided at the boundary: a refused\n * request (a non-2xx answer) spent nothing, so the reservation goes back\n * — against the keys it RECORDED, never against \"now\" — and the caller\n * gets the runtime's fixed sentence, never the provider's (AGL-2815).\n * A result, even a refusal, means the tokens ARE spent: every exit after\n * it meters and none refunds. Anything else thrown never reached the\n * provider and is left to the outer catch, which refunds too.\n */\n const ask = async (\n content: string,\n maxTokens: number,\n tools?: AiTool[],\n ): Promise<AiResult | null> => {\n try {\n const result = await runAiRequest({\n model,\n maxTokens,\n stream: false,\n // The mode's row (AGL-3781): an element's ceiling holds the copy\n // alone, so its row says no thinking; a section's and a post's\n // name nothing and leave the model's own default.\n ...aiRouteSettings(assistStepKindForMode(mode)),\n // The mode's prompt with the acceptable-use rules ahead of it\n // (AGL-2925), from `ai-assist-prompts.ts` so a spec can read what\n // this route sends without standing up the route.\n system: assistModeSystemBlocks(mode),\n messages: [{ role: 'user', content }],\n ...(tools ? { tools } : {}),\n })\n providerAnswered = true\n await meter(result)\n return result\n } catch (error) {\n if (!(error instanceof AiUpstreamError)) throw error\n providerAnswered = true\n await releaseReservation(firestore, orgId, reservation as AssistReservation)\n reservation = null\n res.status(502).json({ error: error.message })\n return null\n }\n }\n\n // Generate section (AGL-169): a constrained-JSON node subtree over the\n // marketplace component allowlist; the response passes the same\n // sanitizer as marketplace installs before it ever reaches a canvas.\n if (mode === 'section') {\n const result = await ask(\n `Section to design: ${instruction}`,\n SECTION_MAX_TOKENS,\n [assistSectionTool()],\n )\n if (!result) return\n if (result.kind === 'refusal') {\n return res.status(502).json({ ...REFUSED, meter: meterFor(result) })\n }\n const read = readAssistSection(result)\n if (read.status !== 'ok') {\n // Unreadable is the provider's fault and rejected is the answer's,\n // the two statuses this route has always drawn that line between.\n return res.status(read.status === 'unreadable' ? 502 : 422).json({ error: read.error })\n }\n // A subtree is going back (AGL-2929): the act, its size and its site in\n // the customer's feed — never the instruction or the copy generated.\n await logAiAssistSection(\n { uid: decoded.uid, email: decoded.email ?? null },\n {\n orgId,\n hostId: hostId || null,\n nodeCount: Object.keys(read.section.nodes).length,\n },\n )\n return res.status(200).json({ section: read.section, meter: meterFor(result) })\n }\n\n const result = await ask(\n mode === 'blog'\n ? [\n title && `Title: ${title}`,\n excerpt && `Excerpt: ${excerpt}`,\n text && `Current body:\\n${text}`,\n `Instruction: ${instruction}`,\n ]\n .filter(Boolean)\n .join('\\n\\n')\n : text\n ? `Current element text:\\n${text}\\n\\nInstruction: ${instruction}`\n : `Write website element text. Instruction: ${instruction}`,\n mode === 'blog' ? BLOG_MAX_TOKENS : ELEMENT_MAX_TOKENS,\n )\n if (!result) return\n if (result.kind === 'refusal') {\n return res.status(502).json({ ...REFUSED, meter: meterFor(result) })\n }\n const output = result.text.trim()\n if (!output) {\n return res.status(502).json({ error: 'Empty AI response', meter: meterFor(result) })\n }\n return res.status(200).json({ text: output, meter: meterFor(result) })\n } catch (error) {\n console.error(error)\n // The provider was never reached — a token verification failure, a DNS\n // error, a socket reset before any response. Nothing was spent, so the\n // reservation must not be. If the provider DID answer, the tokens are\n // real and the reservation stays consumed however the request ends.\n if (firestore && reservation && !providerAnswered) {\n await releaseReservation(firestore, orgId, reservation)\n }\n return res.status(500).json({ error: 'Assist failed' })\n }\n}\n\n/**\n * Hand a reservation back, never letting the refund itself fail the request.\n * Released against the keys the RESERVATION recorded — a reservation taken at\n * 23:59:59 and released at 00:00:01 must not credit the next day.\n */\nasync function releaseReservation(\n firestore: FirebaseFirestore.Firestore,\n orgId: string,\n reservation: AssistReservation,\n): Promise<void> {\n await releaseAssistMessage(firestore, orgId, reservation).catch((error) =>\n console.error('assist reservation release failed', error),\n )\n}\n\n/** Re-emit a lockdown `Response` through the plugin API's res object. */\nasync function forwardRefusal(\n res: PluginApiResponse,\n refusal: Response,\n): Promise<void> {\n const retryAfter = refusal.headers.get('Retry-After')\n if (retryAfter) res.setHeader('Retry-After', retryAfter)\n const body = await refusal.json().catch(() => ({ error: 'locked' }))\n res.status(refusal.status).json(body)\n}\n"],"names":["checkEntitlement","assistCreditsFromUsd","assistFreeTasteRefusalText","assistNotOnPlanText","assistOwnControlRefusalText","resolveEffectivePlan","aiAllotmentRefusalText","resolveAiModelChoice","aiUsageMeter","permissionRefusal","checkRateLimit","featureLockdownRefusal","firebaseAdmin","getOrgForUser","lockdownRefusal","memberHasPermissionOnHost","rateLimitHeaders","aiOverageReservationRefusal","recordUserAiRefusal","estimateAssistCostUsd","publicAssistQuota","recordAssistCost","releaseAssistMessage","reserveAssistMessage","logAiAssistSection","AI_ROUTING_TABLE","aiModelForStep","aiRouteSettings","AiUpstreamError","aiProviderReady","runAiRequest","assistModeSystemBlocks","assistSectionTool","readAssistSection","checkAiClientIpRateLimit","assistModelForMode","mode","assistStepKindForMode","ASSIST_RATE_LIMIT","ASSIST_RATE_WINDOW_MS","SECTION_MAX_TOKENS","maxTokens","BLOG_MAX_TOKENS","ELEMENT_MAX_TOKENS","REFUSED","error","aiAssistHandler","req","res","headers","method","status","json","authorization","idToken","startsWith","slice","length","undefined","body","text","String","instruction","title","excerpt","orgId","trim","hostId","reservation","providerAnswered","firestore","resolved","held","app","decoded","auth","verifyIdToken","uid","org","staff","aiPaused","feature","forwardRefusal","permission","member","entitled","locked","request","rate","limit","windowMs","allowed","name","value","Object","entries","setHeader","ipRate","Date","console","ownControl","refusedBy","allotment","refusal","scope","reason","quota","meter","overage","tier","choice","model","plan","allotmentModels","models","orgModels","meterFor","result","lastCredits","usage","id","auto","free","route","docsPaths","stopReason","catch","ask","content","tools","stream","system","messages","role","releaseReservation","message","kind","read","email","nodeCount","keys","section","nodes","filter","Boolean","join","output","retryAfter","get"],"mappings":"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"}
@@ -155,6 +155,10 @@ export function aiMediaSvgTool() {
155
155
  ],
156
156
  tool: aiMediaSvgTool(),
157
157
  maxTokens: AI_MEDIA_SVG_MAX_TOKENS,
158
+ // The ceiling holds the drawing alone, as it did on a fast tier that
159
+ // could not think; the fast tier's model now thinks unless told not to
160
+ // (AGL-3781).
161
+ thinking: 'off',
158
162
  check: (answer)=>checkAiMediaSvgAnswer(answer, input.aspectRatio, input.style)
159
163
  };
160
164
  const result = await runValidatedGeneration(AI_MEDIA_SVG_KIND, generation);