@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.
- package/index.cjs.js +1 -1
- package/index.d.ts +2 -0
- package/index.es.js +153 -139
- package/lib/ai-client.cjs.js +1 -1
- package/lib/ai-client.es.js +20 -18
- package/lib/ai-seo-metadata.cjs.js +4 -0
- package/lib/ai-seo-metadata.d.ts +127 -0
- package/lib/ai-seo-metadata.es.js +193 -0
- package/lib/ai-vision.cjs.js +3 -0
- package/lib/ai-vision.d.ts +133 -0
- package/lib/ai-vision.es.js +227 -0
- package/package.json +4 -4
|
@@ -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>;
|