@nextblock-cms/cortex 0.15.10 → 0.16.1

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.
@@ -0,0 +1,127 @@
1
+ import { z } from './zod-config';
2
+ /**
3
+ * The shape we require back from the model. Every field is a trimmed, non-empty
4
+ * string: a blank meta description is worse than no meta description at all,
5
+ * because the crawler then indexes an empty tag instead of falling back to the
6
+ * page copy, so we would rather fail the attempt and let the routing fallback try
7
+ * the next model than persist an empty value.
8
+ *
9
+ * `z.strictObject` is deliberate. Models routinely volunteer extra keys such as
10
+ * `keywords`, `slug`, or `notes`; rejecting them here keeps the contract honest
11
+ * and surfaces a drifting prompt as a loud validation error rather than as
12
+ * silently ignored output. The routing layer treats a validation failure as
13
+ * retryable, so a chatty model simply costs us one extra attempt.
14
+ */
15
+ export declare const cortexAiSeoMetadataOutputSchema: z.ZodObject<{
16
+ metaDescription: z.ZodString;
17
+ metaTitle: z.ZodString;
18
+ ogDescription: z.ZodString;
19
+ ogTitle: z.ZodString;
20
+ }, z.core.$strict>;
21
+ export type CortexAiSeoMetadataOutput = z.infer<typeof cortexAiSeoMetadataOutputSchema>;
22
+ /**
23
+ * Length budgets applied AFTER parsing rather than trusted to the prompt.
24
+ *
25
+ * Every model overshoots a stated character budget some of the time — they count
26
+ * tokens, not characters, and they cannot see their own output length while
27
+ * generating it. Asking for "under 60 characters" in the prompt raises the hit
28
+ * rate but never guarantees it, and a meta title that a search engine truncates
29
+ * mid-word in the results list is a user-visible defect. So the prompt states the
30
+ * budget (to get well-shaped copy) and this table enforces it (to get a correct
31
+ * result).
32
+ *
33
+ * The numbers track what search engines and social crawlers actually render:
34
+ * roughly 60 characters before a title is cut in the results list, roughly 160 for
35
+ * a description snippet, and the looser Open Graph card limits used by Facebook,
36
+ * LinkedIn, and Slack unfurls.
37
+ */
38
+ export declare const CORTEX_AI_SEO_METADATA_LENGTH_BUDGETS: {
39
+ readonly metaDescription: 160;
40
+ readonly metaTitle: 60;
41
+ readonly ogDescription: 200;
42
+ readonly ogTitle: 88;
43
+ };
44
+ /**
45
+ * How much page copy we are willing to ship to the model. Long-form posts can run
46
+ * to tens of thousands of characters, and the leading paragraphs carry almost all
47
+ * of the signal a title and description need. Truncating here keeps a single call
48
+ * inside the context window of even the smallest free fallback model, keeps the
49
+ * request cheap, and — because the free tail is rate-limited by tokens per minute
50
+ * as well as by requests — materially improves the odds that the first attempt
51
+ * succeeds instead of falling through the whole registry.
52
+ */
53
+ export declare const CORTEX_AI_SEO_METADATA_CONTENT_BUDGET = 6000;
54
+ /**
55
+ * Shorten `value` to at most `maxLength` characters without splitting a word.
56
+ *
57
+ * The rule is: prefer the last whitespace inside the budget, because that is the
58
+ * only cut point guaranteed to leave a complete word behind. When there is no
59
+ * whitespace inside the budget at all the input is a single long token — a URL, a
60
+ * German compound noun, a CJK sentence written without spaces — and a hard cut at
61
+ * exactly `maxLength` is the only option that both respects the budget and returns
62
+ * something useful, so we take it rather than returning an over-budget string or an
63
+ * empty one.
64
+ *
65
+ * This is exported because it is the piece most likely to be subtly wrong and the
66
+ * piece the unit tests target directly. `ai-vision.ts` imports it rather than
67
+ * defining its own copy, both so the alt-text and metadata paths cannot drift apart
68
+ * in how they shorten a string, and because two modules exporting the same name
69
+ * through the `export *` barrel in src/index.ts would collide.
70
+ */
71
+ export declare function truncateOnWordBoundary(value: string, maxLength: number): string;
72
+ /**
73
+ * Pull the outermost balanced JSON object out of a model response.
74
+ *
75
+ * We ask for bare JSON and most models comply, but "most" is not "all": the same
76
+ * prompt can come back wrapped in ```json fences, prefixed with "Here is the
77
+ * metadata:", or followed by an unsolicited explanation of the choices made.
78
+ * Rather than tighten the prompt forever, we tolerate all three shapes here.
79
+ *
80
+ * The scan is string-aware on purpose. A naive `indexOf('{')` / `lastIndexOf('}')`
81
+ * pair breaks the moment a description legitimately contains a brace — and copy
82
+ * about templating, CSS, or code frequently does. Tracking whether we are inside a
83
+ * JSON string literal, and honouring backslash escapes within it, means braces in
84
+ * content are ignored while braces in structure are counted. Nested objects fall out
85
+ * of the same depth counter for free.
86
+ *
87
+ * Returns `null` rather than throwing so the caller can decide that "no JSON here"
88
+ * is a retryable attempt failure instead of a hard error.
89
+ */
90
+ export declare function extractJsonObject(value: string): string | null;
91
+ export interface GenerateSeoMetadataParams {
92
+ abortSignal?: AbortSignal;
93
+ apiKey?: string | null;
94
+ content: string;
95
+ focusKeyword?: string | null;
96
+ locale?: string | null;
97
+ modelId?: string | null;
98
+ siteTitle?: string | null;
99
+ title?: string | null;
100
+ }
101
+ export interface GenerateSeoMetadataResult {
102
+ attempts: number;
103
+ credentialSource: string;
104
+ metaDescription: string;
105
+ metaTitle: string;
106
+ modelId: string;
107
+ ogDescription: string;
108
+ ogTitle: string;
109
+ }
110
+ /**
111
+ * Generate meta and Open Graph copy for a page in a single model call.
112
+ *
113
+ * This uses `generateText` plus our own JSON extraction rather than
114
+ * `generateObject`, following the precedent set by ai-block-generation.ts and for
115
+ * the reason recorded in ai-cortex-widget-builder.ts: a provider-side JSON schema
116
+ * (`response_format: json_schema`) is rejected outright by several models reachable
117
+ * through OpenRouter, and most of the free fallback tier does not advertise
118
+ * `structured_outputs` at all. Describing the shape in the prompt and validating it
119
+ * ourselves with Zod is the only approach that works across the entire routing
120
+ * chain, and it degrades gracefully — a model that wraps its JSON in fences still
121
+ * succeeds instead of erroring inside the SDK.
122
+ *
123
+ * Routing goes through the ordinary text policy, because every field here is text
124
+ * produced from text. Only the vision path in ai-vision.ts needs a policy of its
125
+ * own.
126
+ */
127
+ export declare function generateCortexAiSeoMetadata(params: GenerateSeoMetadataParams): Promise<GenerateSeoMetadataResult>;
@@ -0,0 +1,193 @@
1
+ import { generateText as p } from "ai";
2
+ import { buildCortexAiRoutingPolicy as T, runWithCortexAiModelFallback as h, omitUnsupportedCortexAiModelOptions as S, getHttpStatusCode as y, isOpenRouterRecoverableRoutingError as E } from "./ai-model-registry.es.js";
3
+ import "./zod-config.es.js";
4
+ import { z as l } from "zod";
5
+ const b = "Cortex AI SEO metadata generation can only be imported from server-side code.";
6
+ function O() {
7
+ if (!(typeof window > "u"))
8
+ throw new Error(b);
9
+ }
10
+ const w = l.strictObject({
11
+ metaDescription: l.string().trim().min(1),
12
+ metaTitle: l.string().trim().min(1),
13
+ ogDescription: l.string().trim().min(1),
14
+ ogTitle: l.string().trim().min(1)
15
+ }), c = {
16
+ metaDescription: 160,
17
+ metaTitle: 60,
18
+ ogDescription: 200,
19
+ ogTitle: 88
20
+ }, A = 6e3, D = 6e4, v = /[\s.,;:!?‐-―\-/\\|&+*_"'([{·•]+$/;
21
+ function d(e, t) {
22
+ const n = e.trim();
23
+ if (t <= 0)
24
+ return "";
25
+ if (n.length <= t)
26
+ return n;
27
+ const o = n.slice(0, t), i = o.search(/\s\S*$/);
28
+ return (i > 0 ? o.slice(0, i) : o).replace(v, "").trim();
29
+ }
30
+ function x(e) {
31
+ const t = e.replace(/```[a-zA-Z0-9]*/g, ""), n = t.indexOf("{");
32
+ if (n === -1)
33
+ return null;
34
+ let o = 0, i = !1, r = !1;
35
+ for (let s = n; s < t.length; s++) {
36
+ const a = t[s];
37
+ if (i) {
38
+ r ? r = !1 : a === "\\" ? r = !0 : a === '"' && (i = !1);
39
+ continue;
40
+ }
41
+ if (a === '"') {
42
+ i = !0;
43
+ continue;
44
+ }
45
+ if (a === "{") {
46
+ o++;
47
+ continue;
48
+ }
49
+ if (a === "}" && (o--, o === 0))
50
+ return t.slice(n, s + 1);
51
+ }
52
+ return null;
53
+ }
54
+ function _() {
55
+ const e = c;
56
+ return [
57
+ "You are NextBlock Cortex AI, an SEO metadata writer for a content management system.",
58
+ "Return ONLY a single JSON object. No markdown fences, no commentary, no explanation, no trailing prose.",
59
+ 'The object must have exactly these four keys, all strings, all non-empty: "metaDescription", "metaTitle", "ogDescription", "ogTitle".',
60
+ "Do not add any other key.",
61
+ `metaTitle must be at most ${e.metaTitle} characters and read as a search-result headline for this specific page.`,
62
+ `metaDescription must be at most ${e.metaDescription} characters, summarise what the page actually delivers, and end as a complete thought.`,
63
+ `ogTitle must be at most ${e.ogTitle} characters and may be slightly more conversational than metaTitle, because it is read on a social card rather than in a results list.`,
64
+ `ogDescription must be at most ${e.ogDescription} characters and should invite a click without over-promising.`,
65
+ "Write plain text. No emoji, no surrounding quotation marks, no HTML, no markdown.",
66
+ "Describe only what the supplied content actually contains. Never invent statistics, prices, dates, awards, or claims that are not in the content.",
67
+ "Never keyword-stuff, never repeat the focus keyword more than twice across all four fields, and never pad a field to reach its character budget."
68
+ ].join(" ");
69
+ }
70
+ function C(e) {
71
+ return [
72
+ "Write SEO metadata for the following page.",
73
+ e.title ? `Page title: ${e.title}` : null,
74
+ e.siteTitle ? `Site name: ${e.siteTitle}. Do not append the site name to metaTitle; the site appends it separately.` : null,
75
+ e.focusKeyword ? `Focus keyword: ${e.focusKeyword}. Use it naturally in metaTitle and metaDescription, at most once each.` : null,
76
+ e.locale ? `Write every field in this locale: ${e.locale}. Match its conventions for capitalisation and punctuation.` : null,
77
+ `Page content:
78
+ ${e.content}`
79
+ ].filter(Boolean).join(`
80
+
81
+ `);
82
+ }
83
+ function I(e) {
84
+ const t = x(e);
85
+ if (!t)
86
+ throw new Error("Cortex AI returned no JSON object for the SEO metadata request.");
87
+ let n;
88
+ try {
89
+ n = JSON.parse(t);
90
+ } catch (i) {
91
+ throw new Error(
92
+ `Cortex AI returned invalid JSON for the SEO metadata request: ${i instanceof Error ? i.message : String(i)}`
93
+ );
94
+ }
95
+ const o = w.parse(n);
96
+ return {
97
+ metaDescription: d(
98
+ o.metaDescription,
99
+ c.metaDescription
100
+ ),
101
+ metaTitle: d(
102
+ o.metaTitle,
103
+ c.metaTitle
104
+ ),
105
+ ogDescription: d(
106
+ o.ogDescription,
107
+ c.ogDescription
108
+ ),
109
+ ogTitle: d(
110
+ o.ogTitle,
111
+ c.ogTitle
112
+ )
113
+ };
114
+ }
115
+ function R(e) {
116
+ const t = y(e);
117
+ if (t === 401 || t === 402 || t === 403)
118
+ return !1;
119
+ if (E(e) || t && t >= 500)
120
+ return !0;
121
+ const n = e instanceof Error ? e.message : String(e);
122
+ return /NoContentGenerated|No content generated|Provider returned error|no JSON object|invalid JSON|invalid_type|too_small|unrecognized_keys|Invalid input|aborted|abort|timeout|timed out/i.test(
123
+ n
124
+ );
125
+ }
126
+ async function j(e) {
127
+ O();
128
+ const t = d(e.content, A);
129
+ if (!t)
130
+ throw new Error("Cortex AI SEO metadata generation requires non-empty page content.");
131
+ const { createCortexAiOpenRouterClient: n } = await import("./ai-client.es.js"), o = await n({
132
+ apiKey: e.apiKey || void 0
133
+ }), i = T({
134
+ credentialSource: o.credentialSource,
135
+ requestedModelId: e.modelId,
136
+ selectedModel: o.modelSelection
137
+ }), r = await h({
138
+ modelIds: i.modelIds,
139
+ shouldRetry: R,
140
+ execute: async (s) => {
141
+ const a = new AbortController(), m = setTimeout(
142
+ () => a.abort(),
143
+ D
144
+ ), u = () => a.abort();
145
+ e.abortSignal?.addEventListener("abort", u, { once: !0 }), e.abortSignal?.aborted && a.abort();
146
+ try {
147
+ const g = S(
148
+ {
149
+ abortSignal: a.signal,
150
+ maxOutputTokens: 700,
151
+ maxRetries: 0,
152
+ prompt: C({
153
+ content: t,
154
+ focusKeyword: e.focusKeyword?.trim() || null,
155
+ locale: e.locale?.trim() || null,
156
+ siteTitle: e.siteTitle?.trim() || null,
157
+ title: e.title?.trim() || null
158
+ }),
159
+ system: _(),
160
+ temperature: 0.3
161
+ },
162
+ {
163
+ modelId: s,
164
+ modelSelection: i.modelSelection
165
+ }
166
+ ), f = await p({
167
+ ...g,
168
+ model: o.model(s)
169
+ });
170
+ return I(f.text);
171
+ } finally {
172
+ clearTimeout(m), e.abortSignal?.removeEventListener("abort", u);
173
+ }
174
+ }
175
+ });
176
+ return {
177
+ attempts: r.attempts.length,
178
+ credentialSource: o.credentialSource,
179
+ metaDescription: r.result.metaDescription,
180
+ metaTitle: r.result.metaTitle,
181
+ modelId: r.modelId,
182
+ ogDescription: r.result.ogDescription,
183
+ ogTitle: r.result.ogTitle
184
+ };
185
+ }
186
+ export {
187
+ A as CORTEX_AI_SEO_METADATA_CONTENT_BUDGET,
188
+ c as CORTEX_AI_SEO_METADATA_LENGTH_BUDGETS,
189
+ w as cortexAiSeoMetadataOutputSchema,
190
+ x as extractJsonObject,
191
+ j as generateCortexAiSeoMetadata,
192
+ d as truncateOnWordBoundary
193
+ };
@@ -0,0 +1,3 @@
1
+ "use strict";Object.defineProperty(exports,Symbol.toStringTag,{value:"Module"});const C=require("ai"),l=require("./ai-model-registry.cjs.js"),R=require("./ai-seo-metadata.cjs.js"),S="Cortex AI vision generation can only be imported from server-side code.";function L(){if(!(typeof window>"u"))throw new Error(S)}const d=["google/gemma-4-31b-it:free","google/gemma-4-26b-a4b-it:free","google/gemini-2.5-flash-lite","qwen/qwen3-vl-32b-instruct","openai/gpt-4o-mini","google/gemini-2.5-flash"],O=[/(?:^|\/)[^/]*-vl(?:[-:]|$)/i,/vision/i,/(?:^|\/)gemini-/i,/(?:^|\/)gemma-[3-9]/i,/(?:^|\/)gpt-(?:4o|4\.1|5)/i,/(?:^|\/)claude-/i,/(?:^|\/)llama-4/i,/(?:^|\/)pixtral/i,/(?:^|\/)mistral-(?:small|medium|large)-3/i,/(?:^|\/)nova-(?:lite|pro|premier)/i],u=125,p={max:1e3,min:20},M=6e4,T=new Set(['"',"'","«","»","‘","’","‚","“","”","„","`"]),v=/^(?:here(?:'s|’s| is)\s+)?(?:the\s+)?alt(?:[-\s]?text)?\s*[:–—-]\s*/i,N=/^(?:this\s+is\s+)?(?:an?|the)?\s*(?:image|photo|photograph|picture|pic|graphic|illustration|rendering|render)\s+(?:of|showing|depicting|that\s+shows)\s+/i;function X(e){const o=new Set,t=[];for(const n of e){const r=n?.trim();!r||o.has(r)||(o.add(r),t.push(r))}return t}function A(e){const o=e?.trim();return o?d.includes(o)?!0:O.some(t=>t.test(o)):!1}function _(e){const o=e.requestedModelId?.trim()||null,t=e.selectedModel?.modelId?.trim()||null,n=e.credentialSource!=="env"&&t&&A(t)?t:null;return{modelIds:X([o,n,...d])}}function f(e){let o=e.trim();for(;o.length>=2&&T.has(o[0])&&T.has(o[o.length-1]);)o=o.slice(1,-1).trim();return o}function U(e){return!e.endsWith(".")||e.endsWith("...")||e.endsWith("…")?e:(e.match(/[.!?](?=\s|$)/g)||[]).length===1?e.slice(0,-1).trimEnd():e}function b(e,o=u){let t=e.replace(/\s+/g," ").trim(),n=!1;for(let r=0;r<2;r++){t=f(t);const a=t.replace(v,"");a!==t&&(t=a.trim(),n=!0),t=f(t);const i=t.replace(N,"");i!==t&&(t=i.trim(),n=!0)}return n&&t&&(t=t.charAt(0).toUpperCase()+t.slice(1)),R.truncateOnWordBoundary(U(t),o)}function q(e){const o=e.trim();if(!o)throw new Error("Cortex AI alt text generation requires an image URL.");let t;try{t=new URL(o)}catch{throw new Error(`Cortex AI alt text generation requires an absolute http(s) image URL, received "${o}". A storage key or site-relative path must be resolved to a publicly fetchable URL first, because the model provider downloads the image server-side.`)}if(t.protocol!=="http:"&&t.protocol!=="https:")throw new Error(`Cortex AI alt text generation requires an http(s) image URL, received the "${t.protocol}" scheme.`);return t}function P(e){return["You are NextBlock Cortex AI, writing the alt attribute for an image on a website.","You are writing for a person using a screen reader who cannot see the image at all.","Describe only what is visibly present in the image: subjects, their appearance, what they are doing, the setting, and any text that appears in the image.","Never state or guess anything you cannot see — no names, no locations, no dates, no brands, no emotions, and no backstory unless they are legible in the image itself.",`Write one short factual description of at most ${e} characters.`,'Do not begin with "image of", "photo of", "picture of", "graphic of", or any similar phrase; the screen reader already announces that this is an image.','Do not add a label such as "Alt text:" and do not wrap the description in quotation marks.',"Do not keyword-stuff, do not list search terms, and do not repeat a word for emphasis.","Return plain text only: one line, no markdown, no HTML, no JSON, no commentary, no alternatives to choose between.","If surrounding page context is supplied, use it ONLY to disambiguate what you can already see — for example to choose the right word for an object or to know which product is pictured. Never use it to add detail that is not visible in the image."].join(" ")}function D(e){return[`Write the alt attribute for this image, at most ${e.maxLength} characters.`,e.context?`Surrounding page context, for disambiguation only: ${e.context}`:null].filter(Boolean).join(`
2
+
3
+ `)}function V(e){const o=l.getHttpStatusCode(e);if(o===401||o===402||o===403)return!1;if(l.isOpenRouterRecoverableRoutingError(e)||o&&o>=500)return!0;const t=e instanceof Error?e.message:String(e);return/NoContentGenerated|No content generated|Provider returned error|empty alt text|image|modality|multimodal|not support|unsupported|aborted|abort|timeout|timed out/i.test(t)}async function k(e){L();const o=q(e.imageUrl),t=Number.isFinite(e.maxLength)?Math.round(e.maxLength):u,n=Math.min(p.max,Math.max(p.min,t)),r=e.context?.replace(/\s+/g," ").trim()||null,{createCortexAiOpenRouterClient:a}=await Promise.resolve().then(()=>require("./ai-client.cjs.js")),i=await a({apiKey:e.apiKey||void 0}),x=_({credentialSource:i.credentialSource,requestedModelId:e.modelId,selectedModel:i.modelSelection}),E=[{content:[{text:D({context:r,maxLength:n}),type:"text"},{image:o,type:"image"}],role:"user"}],c=await l.runWithCortexAiModelFallback({modelIds:x.modelIds,shouldRetry:V,execute:async m=>{const s=new AbortController,I=setTimeout(()=>s.abort(),M),g=()=>s.abort();e.abortSignal?.addEventListener("abort",g,{once:!0}),e.abortSignal?.aborted&&s.abort();try{const w=l.omitUnsupportedCortexAiModelOptions({abortSignal:s.signal,maxOutputTokens:400,maxRetries:0,messages:E,system:P(n),temperature:.2},{modelId:m,modelSelection:i.modelSelection}),y=await C.generateText({...w,model:i.model(m)}),h=b(y.text,n);if(!h)throw new Error("Cortex AI returned empty alt text.");return h}finally{clearTimeout(I),e.abortSignal?.removeEventListener("abort",g)}}});return{altText:c.result,attempts:c.attempts.length,credentialSource:i.credentialSource,modelId:c.modelId}}exports.CORTEX_AI_DEFAULT_ALT_TEXT_MAX_LENGTH=u;exports.CORTEX_AI_VISION_MODEL_FALLBACK_REGISTRY=d;exports.buildCortexAiVisionRoutingPolicy=_;exports.generateCortexAiAltText=k;exports.isKnownVisionCapableCortexAiModelId=A;exports.normalizeGeneratedAltText=b;
@@ -0,0 +1,133 @@
1
+ /**
2
+ * Ordered list of OpenRouter model ids that genuinely accept image input.
3
+ *
4
+ * This list exists precisely BECAUSE the general-purpose fallback registry cannot
5
+ * be reused here. `CORTEX_AI_FREE_MODEL_FALLBACK_REGISTRY` in ai-model-registry.ts
6
+ * is a text-only tier, and `buildCortexAiRoutingPolicy` actively DISCARDS a
7
+ * requested model id whenever the credential came from the shared environment key
8
+ * — it records the request in `ignoredRequestedModelId` and routes to that
9
+ * text-only tier anyway. That is the right behaviour for text (it stops a caller
10
+ * spending the house key on an expensive model) but it is fatal for vision: the
11
+ * call would be dispatched to a model that cannot see the image, and the model
12
+ * would either error on the unsupported content part or, worse, hallucinate a
13
+ * description of an image it never received. Hence a separate registry and a
14
+ * separate policy below.
15
+ *
16
+ * The order is: free tier first so an install with no billing set up still gets
17
+ * working alt text, then paid models in ascending cost, ending with the most
18
+ * capable one for images the cheaper models fumble.
19
+ *
20
+ * - `google/gemma-4-31b-it:free` — the strongest general-purpose free multimodal
21
+ * instruct model on OpenRouter; text output, 262k context, no expiry date set.
22
+ * - `google/gemma-4-26b-a4b-it:free` — its sparse sibling. A second free entry
23
+ * matters more than a marginally better one, because the free tier is rate
24
+ * limited per model and the first choice is the one everybody else hits too.
25
+ * - `google/gemini-2.5-flash-lite` — the cheapest reliable paid vision endpoint,
26
+ * and the first entry that is not subject to free-tier throttling.
27
+ * - `qwen/qwen3-vl-32b-instruct` — a dedicated vision-language model; a useful
28
+ * second opinion because it fails on a different set of images than Gemini does.
29
+ * - `openai/gpt-4o-mini` — the most battle-tested vision endpoint available here,
30
+ * and the id least likely to be retired without warning.
31
+ * - `google/gemini-2.5-flash` — the quality backstop for dense screenshots,
32
+ * diagrams, and photographs with small but load-bearing detail.
33
+ *
34
+ * This list MUST be revisited as OpenRouter's catalog changes. Free-tier ids in
35
+ * particular churn constantly: they are added, throttled, given an
36
+ * `expiration_date`, or silently promoted to paid, at which point a request to them
37
+ * fails with a message `isOpenRouterRecoverableRoutingError` recognises and the
38
+ * chain simply falls through to the next entry. That fallback keeps the feature
39
+ * working, but a registry whose whole free head has expired means every alt-text
40
+ * generation quietly starts costing money — so treat a persistently paid-only
41
+ * outcome as a signal to refresh this list against
42
+ * `https://openrouter.ai/api/v1/models`, filtering on
43
+ * `architecture.input_modalities` containing `image`.
44
+ */
45
+ export declare const CORTEX_AI_VISION_MODEL_FALLBACK_REGISTRY: readonly string[];
46
+ /**
47
+ * Default alt-text budget. 125 characters is the long-standing practical ceiling:
48
+ * older screen readers truncated an `alt` attribute around 125-150 characters, and
49
+ * anything longer is a sign the image is really conveying content that belongs in a
50
+ * caption or a long description instead.
51
+ */
52
+ export declare const CORTEX_AI_DEFAULT_ALT_TEXT_MAX_LENGTH = 125;
53
+ /**
54
+ * Whether we are prepared to send an image to this model id. Registry membership is
55
+ * the certain case; the patterns above cover an admin-selected model from a family
56
+ * we know to be multimodal throughout.
57
+ */
58
+ export declare function isKnownVisionCapableCortexAiModelId(modelId: string | null | undefined): boolean;
59
+ /**
60
+ * Build the model chain for a vision call.
61
+ *
62
+ * Three rules, each of which differs from the text policy for a reason:
63
+ *
64
+ * 1. An explicitly requested model is HONOURED regardless of `credentialSource`.
65
+ * The text policy drops a requested id on an `env` credential so a caller cannot
66
+ * spend the shared house key on a model the operator did not choose. Applying
67
+ * that rule here would silently route an image to a text-only model, which is a
68
+ * correctness failure rather than a cost control. A caller that names a vision
69
+ * model is making a deliberate choice, and the cost ceiling is already bounded
70
+ * by the tiny output budget an alt-text call uses.
71
+ *
72
+ * 2. The admin's stored selection participates only when it is `credentialSource`
73
+ * 'stored' or 'manual' — i.e. the operator's own key, exactly as the text policy
74
+ * treats it — AND the id is recognisably vision-capable. An operator who picked,
75
+ * say, a text-only reasoning model for the page builder should not have that
76
+ * choice quietly break every alt-text generation.
77
+ *
78
+ * 3. The vision registry is always appended as a tail, never conditionally. It is
79
+ * the safety net: whatever the first two rules produce, there is always a known
80
+ * multimodal model behind it.
81
+ *
82
+ * De-duplication preserves first-occurrence order, so a requested model that also
83
+ * appears in the registry stays at the head instead of being tried twice.
84
+ */
85
+ export declare function buildCortexAiVisionRoutingPolicy(params: {
86
+ credentialSource: string;
87
+ requestedModelId?: string | null;
88
+ selectedModel?: {
89
+ modelId: string;
90
+ } | null;
91
+ }): {
92
+ modelIds: string[];
93
+ };
94
+ /**
95
+ * Turn whatever the model said into an `alt` attribute value.
96
+ *
97
+ * Exported as a named function so the whole post-processing contract can be unit
98
+ * tested without a network call — this is the part of the vision path most likely
99
+ * to regress, because every change here is a response to some specific model's
100
+ * verbal tic and it is easy to break an earlier fix while adding the next one.
101
+ *
102
+ * The steps run in this order for a reason: whitespace is collapsed first so a
103
+ * multi-line answer is matched by the single-line label patterns; quote stripping
104
+ * and preamble stripping then alternate, because `Alt text: "A grey cat."` needs
105
+ * both and either order alone leaves one of them behind.
106
+ */
107
+ export declare function normalizeGeneratedAltText(raw: string, maxLength?: number): string;
108
+ export interface GenerateAltTextParams {
109
+ abortSignal?: AbortSignal;
110
+ apiKey?: string | null;
111
+ context?: string | null;
112
+ imageUrl: string;
113
+ maxLength?: number;
114
+ modelId?: string | null;
115
+ }
116
+ export interface GenerateAltTextResult {
117
+ altText: string;
118
+ attempts: number;
119
+ credentialSource: string;
120
+ modelId: string;
121
+ }
122
+ /**
123
+ * Generate an `alt` attribute for an image by actually looking at it.
124
+ *
125
+ * The image is attached as an AI SDK v6 image content part inside a user message.
126
+ * Two consequences of that are worth knowing at the call site: the URL must be
127
+ * publicly fetchable from the server (the SDK downloads it and inlines base64
128
+ * rather than forwarding the link, because the provider is created without
129
+ * `supportedUrls`), and a very large source image inflates the request body on
130
+ * every attempt in the fallback chain. Passing a resized or CDN-transformed URL is
131
+ * therefore materially cheaper than passing the original upload.
132
+ */
133
+ export declare function generateCortexAiAltText(params: GenerateAltTextParams): Promise<GenerateAltTextResult>;