@aexol/spectral 0.9.215 → 0.9.216
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/dist/backend/generated-images.d.ts +90 -0
- package/dist/backend/generated-images.d.ts.map +1 -1
- package/dist/backend/generated-images.js +269 -0
- package/dist/commands/logout.d.ts.map +1 -1
- package/dist/commands/logout.js +4 -0
- package/dist/sdk/ai/providers/openai-completions.d.ts.map +1 -1
- package/dist/sdk/ai/providers/openai-completions.js +13 -1
- package/dist/sdk/ai/providers/openrouter-images.d.ts.map +1 -1
- package/dist/sdk/ai/providers/openrouter-images.js +14 -1
- package/dist/sdk/ai/utils/hosted-image-resolver.d.ts +60 -0
- package/dist/sdk/ai/utils/hosted-image-resolver.d.ts.map +1 -0
- package/dist/sdk/ai/utils/hosted-image-resolver.js +75 -0
- package/dist/sdk/ai/utils/image-source.d.ts +48 -1
- package/dist/sdk/ai/utils/image-source.d.ts.map +1 -1
- package/dist/sdk/ai/utils/image-source.js +82 -5
- package/package.json +1 -1
|
@@ -12,6 +12,18 @@
|
|
|
12
12
|
* 3. `POST {backendUrl}/generated-images/<imageId>/confirm`
|
|
13
13
|
* -> `{ id, size, url }`
|
|
14
14
|
*
|
|
15
|
+
* Downloading works the other way round and has two flavours:
|
|
16
|
+
*
|
|
17
|
+
* A. `GET {backendUrl}/generated-images/<imageId>` (authenticated)
|
|
18
|
+
* -> 302 redirect to a short-lived presigned S3 GET.
|
|
19
|
+
* B. `GET {backendUrl}/generated-images/<imageId>/url`
|
|
20
|
+
* (authenticated) -> `{ url, expiresIn }` where `url` is a presigned
|
|
21
|
+
* S3 GET that needs NO headers at all.
|
|
22
|
+
*
|
|
23
|
+
* Flavour B is the one to hand to a third party that cannot authenticate
|
|
24
|
+
* against our backend (an LLM provider fetching an image, an `<img src>`,
|
|
25
|
+
* ...). See `resolveHostedImageUrl`.
|
|
26
|
+
*
|
|
15
27
|
* Errors from the backend have the shape
|
|
16
28
|
* `{ "error": { "message": "...", "type": "..." } }` and are surfaced as a
|
|
17
29
|
* `GeneratedImageUploadError` carrying the HTTP status and the step that
|
|
@@ -64,6 +76,84 @@ export interface ConfirmResponse {
|
|
|
64
76
|
size: number;
|
|
65
77
|
url: string;
|
|
66
78
|
}
|
|
79
|
+
export interface ResolveHostedImageUrlOptions {
|
|
80
|
+
/** Backend base url, e.g. `https://api.aexol.ai`. */
|
|
81
|
+
backendUrl: string;
|
|
82
|
+
/** Machine JWT (`Authorization: Bearer <token>`). */
|
|
83
|
+
token: string;
|
|
84
|
+
/** Image id. Optional when `url` is given (it is parsed from there). */
|
|
85
|
+
imageId?: string;
|
|
86
|
+
/** Backend-relative (`/generated-images/<id>`) or absolute hosted url. */
|
|
87
|
+
url?: string;
|
|
88
|
+
/** Injectable for tests / alternative runtimes. Defaults to global `fetch`. */
|
|
89
|
+
fetchImpl?: typeof fetch;
|
|
90
|
+
signal?: AbortSignal;
|
|
91
|
+
/** Per-request timeout. Defaults to 60s. */
|
|
92
|
+
timeoutMs?: number;
|
|
93
|
+
/** Diagnostics sink — failures are silent unless this is provided. */
|
|
94
|
+
onWarn?: (message: string) => void;
|
|
95
|
+
}
|
|
96
|
+
export interface HostedImageUrlResult {
|
|
97
|
+
/**
|
|
98
|
+
* Presigned S3 GET url that is fetchable WITHOUT any Authorization header.
|
|
99
|
+
* Valid for `expiresIn` seconds (typically 3600).
|
|
100
|
+
*/
|
|
101
|
+
url: string;
|
|
102
|
+
expiresIn: number;
|
|
103
|
+
}
|
|
104
|
+
/**
|
|
105
|
+
* Extract the hosted image id from a backend-relative path
|
|
106
|
+
* (`/generated-images/<id>`) or from an absolute url
|
|
107
|
+
* (`https://api.aexol.ai/generated-images/<id>?x=1`).
|
|
108
|
+
*
|
|
109
|
+
* Only the PATH is inspected: a query/fragment that happens to contain
|
|
110
|
+
* `/generated-images/<id>` does not make the url one of ours.
|
|
111
|
+
*
|
|
112
|
+
* Returns `null` when the value is not a hosted backend image reference, so
|
|
113
|
+
* callers can use it as a cheap "is this ours?" predicate.
|
|
114
|
+
*/
|
|
115
|
+
export declare function extractHostedImageId(urlOrPath: string): string | null;
|
|
116
|
+
/**
|
|
117
|
+
* The hosted image id carried by `url`, or `null` when `url` is not a hosted
|
|
118
|
+
* backend image reference. Alias of `extractHostedImageId` kept for callers
|
|
119
|
+
* that read better with a url-oriented name.
|
|
120
|
+
*/
|
|
121
|
+
export declare function hostedImageIdFromUrl(url: string): string | null;
|
|
122
|
+
/**
|
|
123
|
+
* True when `url` points at a backend-hosted image
|
|
124
|
+
* (`/generated-images/<id>`), either backend-relative or absolute.
|
|
125
|
+
*
|
|
126
|
+
* SECURITY: an absolute url is only "ours" when it actually points at OUR
|
|
127
|
+
* backend. `backendUrl` is therefore REQUIRED for absolute urls — without it
|
|
128
|
+
* there is nothing to compare against and the answer is `false`, so callers
|
|
129
|
+
* never attach the machine JWT to a host they cannot vouch for. Relative urls
|
|
130
|
+
* always match (they can only ever be resolved against the backend base url).
|
|
131
|
+
*/
|
|
132
|
+
export declare function isHostedImageUrl(url: string, backendUrl?: string): boolean;
|
|
133
|
+
/**
|
|
134
|
+
* Drop cached presigned urls. Omit `imageId` to clear the whole cache (used
|
|
135
|
+
* by tests and after a logout/credential change).
|
|
136
|
+
*/
|
|
137
|
+
export declare function invalidateHostedImageUrl(imageId?: string, backendUrl?: string): void;
|
|
138
|
+
/**
|
|
139
|
+
* Turn a hosted image reference into a presigned url that a third party (LLM
|
|
140
|
+
* provider, browser) can fetch WITHOUT authenticating against our backend.
|
|
141
|
+
*
|
|
142
|
+
* `GET {backendUrl}/generated-images/<id>/url` with the machine JWT returns
|
|
143
|
+
* `{ url, expiresIn }`; the plain `GET /generated-images/<id>` endpoint is
|
|
144
|
+
* auth-gated and 302-redirects, so handing it to a provider only produces a
|
|
145
|
+
* 401 and a silently dropped image.
|
|
146
|
+
*
|
|
147
|
+
* Results are cached per `<backendUrl>|<imageId>` until
|
|
148
|
+
* `PRESIGNED_REFRESH_MARGIN_MS` before their expiry (see
|
|
149
|
+
* `invalidateHostedImageUrl`), so repeated requests for the same image do not
|
|
150
|
+
* each pay a round trip.
|
|
151
|
+
*
|
|
152
|
+
* NEVER throws and never logs: returns `null` on any failure (missing token,
|
|
153
|
+
* non-2xx, network error, malformed body) so callers can fall back to their
|
|
154
|
+
* previous behaviour. Pass `onWarn` to see why.
|
|
155
|
+
*/
|
|
156
|
+
export declare function resolveHostedImageUrl(options: ResolveHostedImageUrlOptions): Promise<HostedImageUrlResult | null>;
|
|
67
157
|
/**
|
|
68
158
|
* Thrown for every failure of the upload sequence (including presigned PUT
|
|
69
159
|
* failures). `status` is `undefined` for network-level errors.
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"generated-images.d.ts","sourceRoot":"","sources":["../../src/backend/generated-images.ts"],"names":[],"mappings":"AAAA
|
|
1
|
+
{"version":3,"file":"generated-images.d.ts","sourceRoot":"","sources":["../../src/backend/generated-images.ts"],"names":[],"mappings":"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"}
|
|
@@ -12,6 +12,18 @@
|
|
|
12
12
|
* 3. `POST {backendUrl}/generated-images/<imageId>/confirm`
|
|
13
13
|
* -> `{ id, size, url }`
|
|
14
14
|
*
|
|
15
|
+
* Downloading works the other way round and has two flavours:
|
|
16
|
+
*
|
|
17
|
+
* A. `GET {backendUrl}/generated-images/<imageId>` (authenticated)
|
|
18
|
+
* -> 302 redirect to a short-lived presigned S3 GET.
|
|
19
|
+
* B. `GET {backendUrl}/generated-images/<imageId>/url`
|
|
20
|
+
* (authenticated) -> `{ url, expiresIn }` where `url` is a presigned
|
|
21
|
+
* S3 GET that needs NO headers at all.
|
|
22
|
+
*
|
|
23
|
+
* Flavour B is the one to hand to a third party that cannot authenticate
|
|
24
|
+
* against our backend (an LLM provider fetching an image, an `<img src>`,
|
|
25
|
+
* ...). See `resolveHostedImageUrl`.
|
|
26
|
+
*
|
|
15
27
|
* Errors from the backend have the shape
|
|
16
28
|
* `{ "error": { "message": "...", "type": "..." } }` and are surfaced as a
|
|
17
29
|
* `GeneratedImageUploadError` carrying the HTTP status and the step that
|
|
@@ -23,6 +35,263 @@
|
|
|
23
35
|
* @module @spectral/backend/generated-images
|
|
24
36
|
*/
|
|
25
37
|
const DEFAULT_TIMEOUT_MS = 60_000;
|
|
38
|
+
/**
|
|
39
|
+
* Presigned urls are refreshed this long before they actually expire, so a
|
|
40
|
+
* url handed to a provider (or an `<img src>`) never dies mid-flight.
|
|
41
|
+
* Mirrors the landing client's refresh margin.
|
|
42
|
+
*/
|
|
43
|
+
const PRESIGNED_REFRESH_MARGIN_MS = 60_000;
|
|
44
|
+
/** `/generated-images/<id>` — matched against the PATHNAME only. */
|
|
45
|
+
const HOSTED_IMAGE_PATH = /\/generated-images\/([^/?#\s]+)/;
|
|
46
|
+
/**
|
|
47
|
+
* Storage ids the backend accepts. Mirrors `ID_PATTERN` / `isValidId` from
|
|
48
|
+
* `backend/src/routes/generated-images/index.ts` so an id that could never
|
|
49
|
+
* exist server-side is never turned into a request (or an S3 key).
|
|
50
|
+
*/
|
|
51
|
+
const HOSTED_IMAGE_ID = /^[A-Za-z0-9_-]{1,64}$/;
|
|
52
|
+
const ABSOLUTE_URL_SCHEME = /^[a-z][a-z0-9+.-]*:/i;
|
|
53
|
+
/** Upper bound on the presigned-url cache so a long session cannot grow it forever. */
|
|
54
|
+
const HOSTED_IMAGE_URL_CACHE_MAX = 200;
|
|
55
|
+
/** Assumed presigned-url lifetime when the backend omits/invalidates `expiresIn`. */
|
|
56
|
+
const DEFAULT_PRESIGNED_TTL_SECONDS = 3600;
|
|
57
|
+
/**
|
|
58
|
+
* `presigned url` cache keyed by `<backendUrl>|<token>|<imageId>`. Resolving a hosted
|
|
59
|
+
* image costs one authenticated round trip per image per request — with
|
|
60
|
+
* multi-turn image conversations (and retries) that is the same url minted
|
|
61
|
+
* over and over, so entries are reused until they are close to expiring.
|
|
62
|
+
*/
|
|
63
|
+
const hostedImageUrlCache = new Map();
|
|
64
|
+
/**
|
|
65
|
+
* Normalized `scheme://host[:port]` origin of an absolute url, or `null` for
|
|
66
|
+
* relative/unparseable input. `URL` already drops default ports (`:443` for
|
|
67
|
+
* https), so `https://api.test:443/x` and `https://api.test/x` compare equal.
|
|
68
|
+
*/
|
|
69
|
+
function originOf(value) {
|
|
70
|
+
try {
|
|
71
|
+
return new URL(value).origin;
|
|
72
|
+
}
|
|
73
|
+
catch {
|
|
74
|
+
return null;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
/**
|
|
78
|
+
* Path portion of an absolute url (`new URL(v).pathname`) or of a relative
|
|
79
|
+
* reference (everything before `?`/`#`). Query and fragment are NEVER part of
|
|
80
|
+
* the result, so `https://cdn.test/a.png?next=/generated-images/abc` cannot
|
|
81
|
+
* masquerade as one of our hosted images.
|
|
82
|
+
*/
|
|
83
|
+
function pathnameOf(value) {
|
|
84
|
+
if (ABSOLUTE_URL_SCHEME.test(value)) {
|
|
85
|
+
try {
|
|
86
|
+
return new URL(value).pathname;
|
|
87
|
+
}
|
|
88
|
+
catch {
|
|
89
|
+
return null;
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
return value.split("#")[0].split("?")[0];
|
|
93
|
+
}
|
|
94
|
+
/** Drop expired entries and evict the oldest ones past the size cap. */
|
|
95
|
+
function purgeHostedImageUrlCache(now = Date.now()) {
|
|
96
|
+
for (const [key, entry] of hostedImageUrlCache) {
|
|
97
|
+
if (entry.expiresAt <= now)
|
|
98
|
+
hostedImageUrlCache.delete(key);
|
|
99
|
+
}
|
|
100
|
+
// Map iteration is insertion-ordered, so the first key is the oldest.
|
|
101
|
+
while (hostedImageUrlCache.size > HOSTED_IMAGE_URL_CACHE_MAX) {
|
|
102
|
+
const oldest = hostedImageUrlCache.keys().next();
|
|
103
|
+
if (oldest.done)
|
|
104
|
+
break;
|
|
105
|
+
hostedImageUrlCache.delete(oldest.value);
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
/**
|
|
109
|
+
* Extract the hosted image id from a backend-relative path
|
|
110
|
+
* (`/generated-images/<id>`) or from an absolute url
|
|
111
|
+
* (`https://api.aexol.ai/generated-images/<id>?x=1`).
|
|
112
|
+
*
|
|
113
|
+
* Only the PATH is inspected: a query/fragment that happens to contain
|
|
114
|
+
* `/generated-images/<id>` does not make the url one of ours.
|
|
115
|
+
*
|
|
116
|
+
* Returns `null` when the value is not a hosted backend image reference, so
|
|
117
|
+
* callers can use it as a cheap "is this ours?" predicate.
|
|
118
|
+
*/
|
|
119
|
+
export function extractHostedImageId(urlOrPath) {
|
|
120
|
+
if (typeof urlOrPath !== "string")
|
|
121
|
+
return null;
|
|
122
|
+
const trimmed = urlOrPath.trim();
|
|
123
|
+
if (!trimmed)
|
|
124
|
+
return null;
|
|
125
|
+
const path = pathnameOf(trimmed);
|
|
126
|
+
if (!path)
|
|
127
|
+
return null;
|
|
128
|
+
const match = path.match(HOSTED_IMAGE_PATH);
|
|
129
|
+
const raw = match?.[1];
|
|
130
|
+
if (!raw)
|
|
131
|
+
return null;
|
|
132
|
+
// Strip an optional extension (`<id>.png`) — the id is the first segment.
|
|
133
|
+
const dot = raw.lastIndexOf(".");
|
|
134
|
+
let id = dot > 0 ? raw.slice(0, dot) : raw;
|
|
135
|
+
try {
|
|
136
|
+
id = decodeURIComponent(id);
|
|
137
|
+
}
|
|
138
|
+
catch {
|
|
139
|
+
// Malformed percent-encoding: keep the raw segment.
|
|
140
|
+
}
|
|
141
|
+
if (!HOSTED_IMAGE_ID.test(id))
|
|
142
|
+
return null;
|
|
143
|
+
return id;
|
|
144
|
+
}
|
|
145
|
+
/**
|
|
146
|
+
* The hosted image id carried by `url`, or `null` when `url` is not a hosted
|
|
147
|
+
* backend image reference. Alias of `extractHostedImageId` kept for callers
|
|
148
|
+
* that read better with a url-oriented name.
|
|
149
|
+
*/
|
|
150
|
+
export function hostedImageIdFromUrl(url) {
|
|
151
|
+
return extractHostedImageId(url);
|
|
152
|
+
}
|
|
153
|
+
/**
|
|
154
|
+
* True when `url` points at a backend-hosted image
|
|
155
|
+
* (`/generated-images/<id>`), either backend-relative or absolute.
|
|
156
|
+
*
|
|
157
|
+
* SECURITY: an absolute url is only "ours" when it actually points at OUR
|
|
158
|
+
* backend. `backendUrl` is therefore REQUIRED for absolute urls — without it
|
|
159
|
+
* there is nothing to compare against and the answer is `false`, so callers
|
|
160
|
+
* never attach the machine JWT to a host they cannot vouch for. Relative urls
|
|
161
|
+
* always match (they can only ever be resolved against the backend base url).
|
|
162
|
+
*/
|
|
163
|
+
export function isHostedImageUrl(url, backendUrl) {
|
|
164
|
+
if (typeof url !== "string")
|
|
165
|
+
return false;
|
|
166
|
+
const trimmed = url.trim();
|
|
167
|
+
if (!trimmed)
|
|
168
|
+
return false;
|
|
169
|
+
if (hostedImageIdFromUrl(trimmed) === null)
|
|
170
|
+
return false;
|
|
171
|
+
if (!ABSOLUTE_URL_SCHEME.test(trimmed))
|
|
172
|
+
return true;
|
|
173
|
+
const base = typeof backendUrl === "string" ? backendUrl.trim() : "";
|
|
174
|
+
if (!base)
|
|
175
|
+
return false;
|
|
176
|
+
const origin = originOf(trimmed);
|
|
177
|
+
const expected = originOf(base);
|
|
178
|
+
return origin !== null && expected !== null && origin === expected;
|
|
179
|
+
}
|
|
180
|
+
/**
|
|
181
|
+
* Cheap, non-cryptographic fingerprint of the credential: a cached presigned
|
|
182
|
+
* url must never be handed out under a different (possibly less privileged)
|
|
183
|
+
* machine JWT than the one that minted it, so the key carries the token too
|
|
184
|
+
* — without keeping the token itself in the map.
|
|
185
|
+
*/
|
|
186
|
+
function credentialFingerprint(token) {
|
|
187
|
+
let hash = 5381;
|
|
188
|
+
for (let index = 0; index < token.length; index += 1) {
|
|
189
|
+
hash = ((hash << 5) + hash + token.charCodeAt(index)) | 0;
|
|
190
|
+
}
|
|
191
|
+
return (hash >>> 0).toString(36);
|
|
192
|
+
}
|
|
193
|
+
function cacheKey(backendUrl, imageId, token) {
|
|
194
|
+
return `${baseUrlOf(backendUrl)}|${credentialFingerprint(token)}|${imageId}`;
|
|
195
|
+
}
|
|
196
|
+
/**
|
|
197
|
+
* Drop cached presigned urls. Omit `imageId` to clear the whole cache (used
|
|
198
|
+
* by tests and after a logout/credential change).
|
|
199
|
+
*/
|
|
200
|
+
export function invalidateHostedImageUrl(imageId, backendUrl) {
|
|
201
|
+
const id = typeof imageId === "string" ? imageId.trim() : "";
|
|
202
|
+
if (!id) {
|
|
203
|
+
hostedImageUrlCache.clear();
|
|
204
|
+
return;
|
|
205
|
+
}
|
|
206
|
+
const base = typeof backendUrl === "string" ? backendUrl.trim() : "";
|
|
207
|
+
const basePrefix = base ? `${baseUrlOf(base)}|` : "";
|
|
208
|
+
for (const key of [...hostedImageUrlCache.keys()]) {
|
|
209
|
+
if (!key.endsWith(`|${id}`))
|
|
210
|
+
continue;
|
|
211
|
+
if (basePrefix && !key.startsWith(basePrefix))
|
|
212
|
+
continue;
|
|
213
|
+
hostedImageUrlCache.delete(key);
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
/**
|
|
217
|
+
* Turn a hosted image reference into a presigned url that a third party (LLM
|
|
218
|
+
* provider, browser) can fetch WITHOUT authenticating against our backend.
|
|
219
|
+
*
|
|
220
|
+
* `GET {backendUrl}/generated-images/<id>/url` with the machine JWT returns
|
|
221
|
+
* `{ url, expiresIn }`; the plain `GET /generated-images/<id>` endpoint is
|
|
222
|
+
* auth-gated and 302-redirects, so handing it to a provider only produces a
|
|
223
|
+
* 401 and a silently dropped image.
|
|
224
|
+
*
|
|
225
|
+
* Results are cached per `<backendUrl>|<imageId>` until
|
|
226
|
+
* `PRESIGNED_REFRESH_MARGIN_MS` before their expiry (see
|
|
227
|
+
* `invalidateHostedImageUrl`), so repeated requests for the same image do not
|
|
228
|
+
* each pay a round trip.
|
|
229
|
+
*
|
|
230
|
+
* NEVER throws and never logs: returns `null` on any failure (missing token,
|
|
231
|
+
* non-2xx, network error, malformed body) so callers can fall back to their
|
|
232
|
+
* previous behaviour. Pass `onWarn` to see why.
|
|
233
|
+
*/
|
|
234
|
+
export async function resolveHostedImageUrl(options) {
|
|
235
|
+
const { backendUrl, token, url, fetchImpl, signal, timeoutMs, onWarn, } = options;
|
|
236
|
+
const explicitId = typeof options.imageId === "string" ? options.imageId.trim() : "";
|
|
237
|
+
const imageId = explicitId || (url ? extractHostedImageId(url) : null);
|
|
238
|
+
if (!backendUrl || !token || !imageId) {
|
|
239
|
+
onWarn?.(`Cannot resolve hosted image url: missing ${!backendUrl ? "backend url" : !token ? "machine token" : "image id"}`);
|
|
240
|
+
return null;
|
|
241
|
+
}
|
|
242
|
+
// SECURITY: `url` is data that came from an image block, i.e. attacker
|
|
243
|
+
// influenceable. Refuse anything that is not a hosted reference on OUR
|
|
244
|
+
// backend origin — otherwise the machine JWT below would be sent to a
|
|
245
|
+
// third-party host (and a foreign `/generated-images/<id>` path would be
|
|
246
|
+
// swapped for a presigned url of one of our tenant's images).
|
|
247
|
+
if (url && !isHostedImageUrl(url, backendUrl)) {
|
|
248
|
+
onWarn?.(`Refusing to resolve a hosted image url for a foreign origin: ${url}`);
|
|
249
|
+
return null;
|
|
250
|
+
}
|
|
251
|
+
const key = cacheKey(backendUrl, imageId, token);
|
|
252
|
+
purgeHostedImageUrlCache();
|
|
253
|
+
const cached = hostedImageUrlCache.get(key);
|
|
254
|
+
if (cached && cached.expireRefreshAt > Date.now()) {
|
|
255
|
+
return {
|
|
256
|
+
url: cached.url,
|
|
257
|
+
expiresIn: Math.max(0, Math.floor((cached.expiresAt - Date.now()) / 1000)),
|
|
258
|
+
};
|
|
259
|
+
}
|
|
260
|
+
const doFetch = fetchImpl ?? ((...args) => fetch(...args));
|
|
261
|
+
try {
|
|
262
|
+
const response = await fetchWithTimeout(joinUrl(baseUrlOf(backendUrl), `/generated-images/${encodeURIComponent(imageId)}/url`), { method: "GET", headers: { Authorization: `Bearer ${token}` } }, { timeoutMs: timeoutMs ?? DEFAULT_TIMEOUT_MS, signal, fetchImpl: doFetch });
|
|
263
|
+
const json = await readJson(response);
|
|
264
|
+
if (!response.ok) {
|
|
265
|
+
const { message } = errorMessageFrom(json, response.status);
|
|
266
|
+
onWarn?.(`Could not resolve hosted image url for ${imageId}: ${message}`);
|
|
267
|
+
return null;
|
|
268
|
+
}
|
|
269
|
+
const payload = isRecord(json) ? json : {};
|
|
270
|
+
const resolvedUrl = typeof payload.url === "string" ? payload.url.trim() : "";
|
|
271
|
+
if (!resolvedUrl) {
|
|
272
|
+
onWarn?.(`Backend response for hosted image ${imageId} is missing "url"`);
|
|
273
|
+
return null;
|
|
274
|
+
}
|
|
275
|
+
// A missing/invalid `expiresIn` must not disable caching (that used to
|
|
276
|
+
// mint a fresh presigned url for every single request) — assume the
|
|
277
|
+
// backend default instead.
|
|
278
|
+
const expiresIn = Number(payload.expiresIn);
|
|
279
|
+
const ttlSeconds = Number.isFinite(expiresIn) && expiresIn > 0 ? expiresIn : DEFAULT_PRESIGNED_TTL_SECONDS;
|
|
280
|
+
const expiresAt = Date.now() + ttlSeconds * 1000;
|
|
281
|
+
hostedImageUrlCache.set(key, {
|
|
282
|
+
url: resolvedUrl,
|
|
283
|
+
expiresAt,
|
|
284
|
+
expireRefreshAt: Math.max(expiresAt - PRESIGNED_REFRESH_MARGIN_MS, Date.now()),
|
|
285
|
+
});
|
|
286
|
+
purgeHostedImageUrlCache();
|
|
287
|
+
return { url: resolvedUrl, expiresIn: ttlSeconds };
|
|
288
|
+
}
|
|
289
|
+
catch (error) {
|
|
290
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
291
|
+
onWarn?.(`Could not resolve hosted image url for ${imageId}: ${message}`);
|
|
292
|
+
return null;
|
|
293
|
+
}
|
|
294
|
+
}
|
|
26
295
|
/**
|
|
27
296
|
* Thrown for every failure of the upload sequence (including presigned PUT
|
|
28
297
|
* failures). `status` is `undefined` for network-level errors.
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"logout.d.ts","sourceRoot":"","sources":["../../src/commands/logout.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;
|
|
1
|
+
{"version":3,"file":"logout.d.ts","sourceRoot":"","sources":["../../src/commands/logout.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AAOH,wBAAsB,SAAS,IAAI,OAAO,CAAC,IAAI,CAAC,CAgB/C"}
|
package/dist/commands/logout.js
CHANGED
|
@@ -5,10 +5,14 @@
|
|
|
5
5
|
* filesystem itself misbehaves (in which case we surface the OS error).
|
|
6
6
|
*/
|
|
7
7
|
import pc from "picocolors";
|
|
8
|
+
import { invalidateHostedImageUrl } from "../backend/generated-images.js";
|
|
8
9
|
import { CONFIG_FILE, deleteConfig } from "../config.js";
|
|
9
10
|
export async function runLogout() {
|
|
10
11
|
try {
|
|
11
12
|
const removed = await deleteConfig();
|
|
13
|
+
// Presigned urls are minted under the (now deleted) credentials — drop
|
|
14
|
+
// them so a later login cannot hand out a url minted by the old identity.
|
|
15
|
+
invalidateHostedImageUrl();
|
|
12
16
|
if (removed) {
|
|
13
17
|
process.stdout.write(pc.green(`✓ Logged out. Removed ${CONFIG_FILE}\n`));
|
|
14
18
|
}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"openai-completions.d.ts","sourceRoot":"","sources":["../../../../src/sdk/ai/providers/openai-completions.ts"],"names":[],"mappings":"
|
|
1
|
+
{"version":3,"file":"openai-completions.d.ts","sourceRoot":"","sources":["../../../../src/sdk/ai/providers/openai-completions.ts"],"names":[],"mappings":"AAGA,OAAO,KAAK,EAMX,0BAA0B,EAE1B,MAAM,sCAAsC,CAAC;AAG9C,OAAO,KAAK,EAGX,OAAO,EAGP,KAAK,EACL,uBAAuB,EACvB,mBAAmB,EAEnB,cAAc,EACd,aAAa,EAMb,MAAM,aAAa,CAAC;AAmErB,MAAM,WAAW,wBAAyB,SAAQ,aAAa;IAC9D,UAAU,CAAC,EACR,MAAM,GACN,MAAM,GACN,UAAU,GACV;QAAE,IAAI,EAAE,UAAU,CAAC;QAAC,QAAQ,EAAE;YAAE,IAAI,EAAE,MAAM,CAAA;SAAE,CAAA;KAAE,CAAC;IACpD,eAAe,CAAC,EAAE,SAAS,GAAG,KAAK,GAAG,QAAQ,GAAG,MAAM,GAAG,OAAO,CAAC;CAClE;AAED,KAAK,+BAA+B,GAAG,QAAQ,CAAC,uBAAuB,CAAC,CAAC;AAqRzE,eAAO,MAAM,uBAAuB,EAAE,cAAc,CACnD,oBAAoB,EACpB,wBAAwB,CAocxB,CAAC;AAEF,eAAO,MAAM,6BAA6B,EAAE,cAAc,CACzD,oBAAoB,EACpB,mBAAmB,CAyBnB,CAAC;AAsMF,wBAAgB,eAAe,CAC9B,KAAK,EAAE,KAAK,CAAC,oBAAoB,CAAC,EAClC,OAAO,EAAE,OAAO,EAChB,MAAM,EAAE,+BAA+B,GACrC,0BAA0B,EAAE,CA0R9B"}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import OpenAI from "openai";
|
|
2
|
+
import { backendHostedImageResolver } from "../utils/hosted-image-resolver.js";
|
|
2
3
|
import { getApiUrl, readConfig } from "../../../config.js";
|
|
3
4
|
import { getEnvApiKey } from "../env-api-keys.js";
|
|
4
5
|
import { calculateCost, clampThinkingLevel } from "../models.js";
|
|
@@ -311,10 +312,21 @@ export const streamOpenAICompletions = (model, context, options) => {
|
|
|
311
312
|
// downgraded by `buildParams` anyway, so fetching them would only
|
|
312
313
|
// burn bandwidth and memory.
|
|
313
314
|
const modelSupportsImages = model.input.includes("image");
|
|
315
|
+
// Handing `/generated-images/<id>` to the provider would be a 401 —
|
|
316
|
+
// swap it for a presigned url (lazy: only touches config/machine
|
|
317
|
+
// store when such an image is actually in the context).
|
|
318
|
+
const hostedImages = backendHostedImageResolver({
|
|
319
|
+
onWarn: options?.onWarn ?? defaultWarn,
|
|
320
|
+
// Explicit override (e.g. a local backend) wins; otherwise the
|
|
321
|
+
// backend url from the machine routing resolves relative urls.
|
|
322
|
+
baseUrl: () => backendImageBaseUrl(options?.imageBaseUrl),
|
|
323
|
+
});
|
|
314
324
|
const requestContext = modelSupportsImages
|
|
315
325
|
? await resolveImagesForContext(context, {
|
|
316
326
|
signal: options?.signal,
|
|
317
|
-
baseUrl:
|
|
327
|
+
baseUrl: hostedImages.baseUrl,
|
|
328
|
+
resolveHostedUrl: hostedImages.resolveHostedUrl,
|
|
329
|
+
authHeaders: hostedImages.authHeaders,
|
|
318
330
|
onWarn: options?.onWarn ?? defaultWarn,
|
|
319
331
|
})
|
|
320
332
|
: context;
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"openrouter-images.d.ts","sourceRoot":"","sources":["../../../../src/sdk/ai/providers/openrouter-images.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EACX,eAAe,EAEf,aAAa,EACb,WAAW,EACX,qBAAqB,EAGrB,MAAM,aAAa,CAAC;
|
|
1
|
+
{"version":3,"file":"openrouter-images.d.ts","sourceRoot":"","sources":["../../../../src/sdk/ai/providers/openrouter-images.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EACX,eAAe,EAEf,aAAa,EACb,WAAW,EACX,qBAAqB,EAGrB,MAAM,aAAa,CAAC;AA2GrB,wBAAsB,wBAAwB,CAC7C,KAAK,EAAE,WAAW,CAAC,mBAAmB,CAAC,EACvC,OAAO,EAAE,aAAa,EACtB,OAAO,GAAE,qBAA0B,GACjC,OAAO,CAAC,eAAe,CAAC,CAyH1B"}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { getEnvApiKey } from "../env-api-keys.js";
|
|
2
2
|
import { imageSourceUrlForProvider, resolveImagesForInput } from "../utils/image-source.js";
|
|
3
|
+
import { backendHostedImageResolver } from "../utils/hosted-image-resolver.js";
|
|
3
4
|
/**
|
|
4
5
|
* Image generation routing.
|
|
5
6
|
*
|
|
@@ -39,10 +40,22 @@ function textFromContext(context) {
|
|
|
39
40
|
async function referencesFromContext(context, options = {}) {
|
|
40
41
|
const backendUrl = typeof options.backendUrl === "string" ? options.backendUrl : undefined;
|
|
41
42
|
const onWarn = typeof options.onWarn === "function" ? options.onWarn : undefined;
|
|
43
|
+
// `/generated-images/<id>` is auth-gated: a 401 for anything that does not
|
|
44
|
+
// send our machine JWT. Exchange it for a presigned url instead.
|
|
45
|
+
// NOTE: `options.apiKey` is deliberately NOT used here — on the fallback
|
|
46
|
+
// path it is a local OpenRouter key that must never be sent to our
|
|
47
|
+
// backend. The machine JWT comes from `resolveBackendRouting()`, which is
|
|
48
|
+
// lazy + memoized (only awaited for hosted images) and returns null when
|
|
49
|
+
// the CLI is not logged in.
|
|
50
|
+
const hostedImages = backendHostedImageResolver({ onWarn, baseUrl: backendUrl });
|
|
42
51
|
const input = await resolveImagesForInput(context.input, {
|
|
43
52
|
// Backend-relative urls (`/generated-images/<id>`) resolve against the
|
|
44
53
|
// same backend url this request is routed through.
|
|
45
|
-
baseUrl:
|
|
54
|
+
baseUrl: hostedImages.baseUrl,
|
|
55
|
+
resolveHostedUrl: hostedImages.resolveHostedUrl,
|
|
56
|
+
authHeaders: hostedImages.authHeaders,
|
|
57
|
+
// A cancelled request must not wait out the 60s resolve timeout.
|
|
58
|
+
signal: options.signal,
|
|
46
59
|
onWarn,
|
|
47
60
|
});
|
|
48
61
|
return input
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared wiring for backend-hosted images in provider requests.
|
|
3
|
+
*
|
|
4
|
+
* The backend serves conversation images from S3 behind
|
|
5
|
+
* `GET /generated-images/<id>`, which is AUTH-GATED: an LLM provider
|
|
6
|
+
* (OpenAI, Anthropic, OpenRouter, ...) fetching that url anonymously gets a
|
|
7
|
+
* 401 and the image is silently dropped. `GET /generated-images/<id>/url`
|
|
8
|
+
* — behind the machine JWT — returns a presigned S3 url that anyone can
|
|
9
|
+
* fetch, so that is what we hand to the provider.
|
|
10
|
+
*
|
|
11
|
+
* `resolveBackendRouting()` supplies both the backend url and the machine
|
|
12
|
+
* JWT. It is lazy (config + machine store are only read when a hosted image
|
|
13
|
+
* is actually present) and memoized per resolver instance, and it degrades to
|
|
14
|
+
* `null` when the CLI is not logged in — in which case every hook here is a
|
|
15
|
+
* no-op and behaviour falls back to whatever it was before.
|
|
16
|
+
*
|
|
17
|
+
* @module @spectral/ai/utils/hosted-image-resolver
|
|
18
|
+
*/
|
|
19
|
+
export interface BackendHostedImageResolverOptions {
|
|
20
|
+
/** Diagnostics sink — resolution failures are silent without it. */
|
|
21
|
+
onWarn?: (message: string) => void;
|
|
22
|
+
/**
|
|
23
|
+
* Explicit backend base url (or a lazy getter for it). Wins over the url
|
|
24
|
+
* from `resolveBackendRouting()`, so per-request overrides keep working;
|
|
25
|
+
* used to resolve backend-relative image urls (`/generated-images/<id>`).
|
|
26
|
+
*/
|
|
27
|
+
baseUrl?: string | (() => string | undefined | Promise<string | undefined>);
|
|
28
|
+
}
|
|
29
|
+
export interface BackendHostedImageResolver {
|
|
30
|
+
/**
|
|
31
|
+
* Swap a hosted url for a presigned one. Returns `null` when the url is
|
|
32
|
+
* not resolvable (not logged in, foreign origin, backend error, ...) so
|
|
33
|
+
* the caller can fall back to downloading + inlining the bytes itself.
|
|
34
|
+
*/
|
|
35
|
+
resolveHostedUrl: (url: string, options?: {
|
|
36
|
+
signal?: AbortSignal;
|
|
37
|
+
timeoutMs?: number;
|
|
38
|
+
}) => Promise<string | null>;
|
|
39
|
+
/**
|
|
40
|
+
* `Authorization: Bearer <machine JWT>` for downloading the image bytes
|
|
41
|
+
* from our own backend. Only ever attached to hosted backend urls.
|
|
42
|
+
*/
|
|
43
|
+
authHeaders: () => Promise<Record<string, string> | undefined>;
|
|
44
|
+
/** Backend base url for resolving backend-relative image urls. */
|
|
45
|
+
baseUrl: () => Promise<string | undefined>;
|
|
46
|
+
/**
|
|
47
|
+
* The backend url `resolveHostedUrl` authenticates against — i.e. the
|
|
48
|
+
* origin an absolute image url has to match to be considered ours.
|
|
49
|
+
* `undefined` when the CLI is not logged in (nothing is resolvable then).
|
|
50
|
+
*/
|
|
51
|
+
backendUrl: () => Promise<string | undefined>;
|
|
52
|
+
}
|
|
53
|
+
/**
|
|
54
|
+
* Build the hosted-image hooks for a single request.
|
|
55
|
+
*
|
|
56
|
+
* Cheap to construct: nothing is read from disk/config until one of the
|
|
57
|
+
* returned functions is actually awaited.
|
|
58
|
+
*/
|
|
59
|
+
export declare function backendHostedImageResolver(options?: BackendHostedImageResolverOptions): BackendHostedImageResolver;
|
|
60
|
+
//# sourceMappingURL=hosted-image-resolver.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"hosted-image-resolver.d.ts","sourceRoot":"","sources":["../../../../src/sdk/ai/utils/hosted-image-resolver.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;GAiBG;AAMH,MAAM,WAAW,iCAAiC;IACjD,oEAAoE;IACpE,MAAM,CAAC,EAAE,CAAC,OAAO,EAAE,MAAM,KAAK,IAAI,CAAC;IACnC;;;;OAIG;IACH,OAAO,CAAC,EAAE,MAAM,GAAG,CAAC,MAAM,MAAM,GAAG,SAAS,GAAG,OAAO,CAAC,MAAM,GAAG,SAAS,CAAC,CAAC,CAAC;CAC5E;AAED,MAAM,WAAW,0BAA0B;IAC1C;;;;OAIG;IACH,gBAAgB,EAAE,CACjB,GAAG,EAAE,MAAM,EACX,OAAO,CAAC,EAAE;QAAE,MAAM,CAAC,EAAE,WAAW,CAAC;QAAC,SAAS,CAAC,EAAE,MAAM,CAAA;KAAE,KAClD,OAAO,CAAC,MAAM,GAAG,IAAI,CAAC,CAAC;IAC5B;;;OAGG;IACH,WAAW,EAAE,MAAM,OAAO,CAAC,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,GAAG,SAAS,CAAC,CAAC;IAC/D,kEAAkE;IAClE,OAAO,EAAE,MAAM,OAAO,CAAC,MAAM,GAAG,SAAS,CAAC,CAAC;IAC3C;;;;OAIG;IACH,UAAU,EAAE,MAAM,OAAO,CAAC,MAAM,GAAG,SAAS,CAAC,CAAC;CAC9C;AAED;;;;;GAKG;AACH,wBAAgB,0BAA0B,CACzC,OAAO,GAAE,iCAAsC,GAC7C,0BAA0B,CA6C5B"}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared wiring for backend-hosted images in provider requests.
|
|
3
|
+
*
|
|
4
|
+
* The backend serves conversation images from S3 behind
|
|
5
|
+
* `GET /generated-images/<id>`, which is AUTH-GATED: an LLM provider
|
|
6
|
+
* (OpenAI, Anthropic, OpenRouter, ...) fetching that url anonymously gets a
|
|
7
|
+
* 401 and the image is silently dropped. `GET /generated-images/<id>/url`
|
|
8
|
+
* — behind the machine JWT — returns a presigned S3 url that anyone can
|
|
9
|
+
* fetch, so that is what we hand to the provider.
|
|
10
|
+
*
|
|
11
|
+
* `resolveBackendRouting()` supplies both the backend url and the machine
|
|
12
|
+
* JWT. It is lazy (config + machine store are only read when a hosted image
|
|
13
|
+
* is actually present) and memoized per resolver instance, and it degrades to
|
|
14
|
+
* `null` when the CLI is not logged in — in which case every hook here is a
|
|
15
|
+
* no-op and behaviour falls back to whatever it was before.
|
|
16
|
+
*
|
|
17
|
+
* @module @spectral/ai/utils/hosted-image-resolver
|
|
18
|
+
*/
|
|
19
|
+
import { isHostedImageUrl, resolveHostedImageUrl } from "../../../backend/generated-images.js";
|
|
20
|
+
import { resolveBackendRouting } from "../../../backend/machine-routing.js";
|
|
21
|
+
/**
|
|
22
|
+
* Build the hosted-image hooks for a single request.
|
|
23
|
+
*
|
|
24
|
+
* Cheap to construct: nothing is read from disk/config until one of the
|
|
25
|
+
* returned functions is actually awaited.
|
|
26
|
+
*/
|
|
27
|
+
export function backendHostedImageResolver(options = {}) {
|
|
28
|
+
const { onWarn, baseUrl } = options;
|
|
29
|
+
let routing;
|
|
30
|
+
// Memoized so a context with five hosted images reads the machine store
|
|
31
|
+
// once, and text-only requests never touch it at all.
|
|
32
|
+
const getRouting = () => (routing ??= resolveBackendRouting().catch(() => null));
|
|
33
|
+
const resolveExplicitBaseUrl = async () => {
|
|
34
|
+
if (baseUrl === undefined)
|
|
35
|
+
return undefined;
|
|
36
|
+
if (typeof baseUrl === "string")
|
|
37
|
+
return baseUrl.trim() || undefined;
|
|
38
|
+
try {
|
|
39
|
+
const value = await baseUrl();
|
|
40
|
+
return typeof value === "string" && value.trim() ? value.trim() : undefined;
|
|
41
|
+
}
|
|
42
|
+
catch {
|
|
43
|
+
return undefined;
|
|
44
|
+
}
|
|
45
|
+
};
|
|
46
|
+
return {
|
|
47
|
+
resolveHostedUrl: async (url, options) => {
|
|
48
|
+
const resolved = await getRouting();
|
|
49
|
+
if (!resolved)
|
|
50
|
+
return null;
|
|
51
|
+
// SECURITY: never send the machine JWT to (or mint a presigned url
|
|
52
|
+
// for) a host that is not our backend — a third-party url must not
|
|
53
|
+
// be able to impersonate `/generated-images/<id>`.
|
|
54
|
+
if (!isHostedImageUrl(url, resolved.backendUrl)) {
|
|
55
|
+
onWarn?.(`Refusing to resolve a hosted image url for a foreign origin: ${url}`);
|
|
56
|
+
return null;
|
|
57
|
+
}
|
|
58
|
+
const result = await resolveHostedImageUrl({
|
|
59
|
+
backendUrl: resolved.backendUrl,
|
|
60
|
+
token: resolved.machineJwt,
|
|
61
|
+
url,
|
|
62
|
+
signal: options?.signal,
|
|
63
|
+
timeoutMs: options?.timeoutMs,
|
|
64
|
+
onWarn,
|
|
65
|
+
});
|
|
66
|
+
return result?.url ?? null;
|
|
67
|
+
},
|
|
68
|
+
authHeaders: async () => {
|
|
69
|
+
const resolved = await getRouting();
|
|
70
|
+
return resolved ? { Authorization: `Bearer ${resolved.machineJwt}` } : undefined;
|
|
71
|
+
},
|
|
72
|
+
baseUrl: async () => (await resolveExplicitBaseUrl()) ?? (await getRouting())?.backendUrl,
|
|
73
|
+
backendUrl: async () => (await getRouting())?.backendUrl,
|
|
74
|
+
};
|
|
75
|
+
}
|
|
@@ -9,8 +9,11 @@
|
|
|
9
9
|
*
|
|
10
10
|
* - `data` present -> `data:<mimeType>;base64,<data>` (unchanged, legacy safe)
|
|
11
11
|
* - `url` public -> passed through as-is (no base64 in the context!)
|
|
12
|
+
* - `url` backend-hosted -> swapped for a presigned, headerless-fetchable
|
|
13
|
+
* url (`/generated-images/<id>` is what the
|
|
14
|
+
* backend returns; it is AUTH-GATED, so handing
|
|
15
|
+
* it to a provider only yields a 401)
|
|
12
16
|
* - `url` relative -> resolved against the backend base url first
|
|
13
|
-
* (`/generated-images/<id>` is what the backend returns)
|
|
14
17
|
* - `url` local/private -> fetched and inlined **at send time only**
|
|
15
18
|
*
|
|
16
19
|
* The inlining is deliberately scoped to a single request: the fetched bytes
|
|
@@ -38,6 +41,29 @@ export interface ResolveImageOptions {
|
|
|
38
41
|
* actually present.
|
|
39
42
|
*/
|
|
40
43
|
baseUrl?: string | (() => string | undefined | Promise<string | undefined>);
|
|
44
|
+
/**
|
|
45
|
+
* Exchange a backend-hosted image url (`/generated-images/<id>`) for a
|
|
46
|
+
* short-lived presigned url that the provider can fetch WITHOUT any
|
|
47
|
+
* Authorization header (see `resolveHostedImageUrl` in
|
|
48
|
+
* `backend/generated-images.ts`).
|
|
49
|
+
*
|
|
50
|
+
* Return `null`/`undefined` to fall back to the download+inline path.
|
|
51
|
+
* Only ever called for urls where `isHostedBackendImageUrl()` is true —
|
|
52
|
+
* i.e. relative urls and absolute urls on the backend origin.
|
|
53
|
+
*/
|
|
54
|
+
resolveHostedUrl?: (url: string, options?: {
|
|
55
|
+
signal?: AbortSignal;
|
|
56
|
+
timeoutMs?: number;
|
|
57
|
+
}) => Promise<string | null | undefined>;
|
|
58
|
+
/**
|
|
59
|
+
* Headers (in practice `Authorization: Bearer <machine JWT>`) used when the
|
|
60
|
+
* image bytes have to be downloaded for inlining. May be a lazy getter so
|
|
61
|
+
* the credentials are only read when a hosted image is actually present.
|
|
62
|
+
*
|
|
63
|
+
* SECURITY: these are only ever attached to a backend-hosted image url —
|
|
64
|
+
* never to a third-party host the credentials were not issued for.
|
|
65
|
+
*/
|
|
66
|
+
authHeaders?: Record<string, string> | (() => Promise<Record<string, string> | undefined>);
|
|
41
67
|
onWarn?: (message: string) => void;
|
|
42
68
|
}
|
|
43
69
|
export interface ResolvedImageSource {
|
|
@@ -52,6 +78,11 @@ export interface ResolvedImageSource {
|
|
|
52
78
|
* (inline `data` present, or fetched from a local url).
|
|
53
79
|
*/
|
|
54
80
|
data?: string;
|
|
81
|
+
/**
|
|
82
|
+
* True when `url` is a presigned url returned by `resolveHostedUrl` (i.e.
|
|
83
|
+
* it replaced an auth-gated `/generated-images/<id>` reference).
|
|
84
|
+
*/
|
|
85
|
+
hostedResolved?: boolean;
|
|
55
86
|
}
|
|
56
87
|
/**
|
|
57
88
|
* True when `rawUrl` has no scheme, i.e. it is relative and therefore
|
|
@@ -74,6 +105,22 @@ export declare function resolveImageUrlAgainstBase(rawUrl: string, baseUrl?: str
|
|
|
74
105
|
* resolved against a base url.
|
|
75
106
|
*/
|
|
76
107
|
export declare function isLocalOrPrivateImageUrl(rawUrl: string): boolean;
|
|
108
|
+
/**
|
|
109
|
+
* True for a backend-hosted image reference, either backend-relative
|
|
110
|
+
* (`/generated-images/<id>`) or absolute
|
|
111
|
+
* (`https://api.aexol.ai/generated-images/<id>`).
|
|
112
|
+
*
|
|
113
|
+
* `backendUrl` is REQUIRED to accept an ABSOLUTE url: without it there is no
|
|
114
|
+
* origin to compare against, and a foreign host that happens to expose
|
|
115
|
+
* `/generated-images/<id>` must never be treated as ours (that would send the
|
|
116
|
+
* machine JWT to it, and would let it swap in a presigned url of one of our
|
|
117
|
+
* tenant's images). Relative urls are always accepted — they can only ever be
|
|
118
|
+
* resolved against the backend base url.
|
|
119
|
+
*
|
|
120
|
+
* Those endpoints are auth-gated: a provider fetching them without an
|
|
121
|
+
* `Authorization` header gets a 401 and never sees the image.
|
|
122
|
+
*/
|
|
123
|
+
export declare function isHostedBackendImageUrl(rawUrl: string, backendUrl?: string | null): boolean;
|
|
77
124
|
export declare function hasInlineImageData(image: ImageContent): image is ImageContent & {
|
|
78
125
|
data: string;
|
|
79
126
|
};
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"image-source.d.ts","sourceRoot":"","sources":["../../../../src/sdk/ai/utils/image-source.ts"],"names":[],"mappings":"AAAA
|
|
1
|
+
{"version":3,"file":"image-source.d.ts","sourceRoot":"","sources":["../../../../src/sdk/ai/utils/image-source.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;GAuBG;AAGH,OAAO,KAAK,EAAE,OAAO,EAAE,YAAY,EAAW,MAAM,aAAa,CAAC;AAElE,8EAA8E;AAC9E,eAAO,MAAM,wBAAwB,QAAmB,CAAC;AAEzD,iEAAiE;AACjE,eAAO,MAAM,+BAA+B,QAAS,CAAC;AAEtD,MAAM,WAAW,mBAAmB;IACnC,mEAAmE;IACnE,MAAM,CAAC,EAAE,WAAW,CAAC;IACrB,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,cAAc,CAAC,EAAE,MAAM,CAAC;IACxB,mDAAmD;IACnD,SAAS,CAAC,EAAE,OAAO,KAAK,CAAC;IACzB;;;;;OAKG;IACH,OAAO,CAAC,EAAE,MAAM,GAAG,CAAC,MAAM,MAAM,GAAG,SAAS,GAAG,OAAO,CAAC,MAAM,GAAG,SAAS,CAAC,CAAC,CAAC;IAC5E;;;;;;;;;OASG;IACH,gBAAgB,CAAC,EAAE,CAClB,GAAG,EAAE,MAAM,EACX,OAAO,CAAC,EAAE;QAAE,MAAM,CAAC,EAAE,WAAW,CAAC;QAAC,SAAS,CAAC,EAAE,MAAM,CAAA;KAAE,KAClD,OAAO,CAAC,MAAM,GAAG,IAAI,GAAG,SAAS,CAAC,CAAC;IACxC;;;;;;;OAOG;IACH,WAAW,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,GAAG,CAAC,MAAM,OAAO,CAAC,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,GAAG,SAAS,CAAC,CAAC,CAAC;IAC3F,MAAM,CAAC,EAAE,CAAC,OAAO,EAAE,MAAM,KAAK,IAAI,CAAC;CACnC;AAED,MAAM,WAAW,mBAAmB;IACnC,mEAAmE;IACnE,GAAG,EAAE,MAAM,CAAC;IACZ,2DAA2D;IAC3D,OAAO,EAAE,OAAO,CAAC;IACjB,QAAQ,EAAE,MAAM,CAAC;IACjB;;;;OAIG;IACH,IAAI,CAAC,EAAE,MAAM,CAAC;IACd;;;OAGG;IACH,cAAc,CAAC,EAAE,OAAO,CAAC;CACzB;AAuCD;;;GAGG;AACH,wBAAgB,kBAAkB,CAAC,MAAM,EAAE,MAAM,GAAG,OAAO,CAI1D;AAED;;;GAGG;AACH,wBAAgB,0BAA0B,CAAC,MAAM,EAAE,MAAM,EAAE,OAAO,CAAC,EAAE,MAAM,GAAG,MAAM,CAUnF;AAmBD;;;;;;;;;GASG;AACH,wBAAgB,wBAAwB,CAAC,MAAM,EAAE,MAAM,GAAG,OAAO,CA4BhE;AAcD;;;;;;;;;;;;;;GAcG;AACH,wBAAgB,uBAAuB,CAAC,MAAM,EAAE,MAAM,EAAE,UAAU,CAAC,EAAE,MAAM,GAAG,IAAI,GAAG,OAAO,CAE3F;AAqBD,wBAAgB,kBAAkB,CAAC,KAAK,EAAE,YAAY,GAAG,KAAK,IAAI,YAAY,GAAG;IAAE,IAAI,EAAE,MAAM,CAAA;CAAE,CAEhG;AAED,wEAAwE;AACxE,wBAAgB,eAAe,CAAC,IAAI,EAAE,MAAM,EAAE,QAAQ,EAAE,MAAM,GAAG,MAAM,CAGtE;AAED;;;;GAIG;AACH,wBAAgB,mBAAmB,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,CAIxD;AAoBD;;;;;;GAMG;AACH,wBAAsB,uBAAuB,CAC5C,KAAK,EAAE,YAAY,EACnB,OAAO,GAAE,mBAAwB,GAC/B,OAAO,CAAC,mBAAmB,GAAG,IAAI,CAAC,CA2GrC;AAcD;;;;;;;;;GASG;AACH,wBAAsB,uBAAuB,CAC5C,OAAO,EAAE,OAAO,EAChB,OAAO,GAAE,mBAAwB,GAC/B,OAAO,CAAC,OAAO,CAAC,CAoDlB;AAED;;GAEG;AACH,wBAAsB,qBAAqB,CAC1C,KAAK,EAAE,KAAK,CAAC;IAAE,IAAI,EAAE,MAAM,CAAA;CAAE,CAAC,EAC9B,OAAO,GAAE,mBAAwB,GAC/B,OAAO,CAAC,KAAK,CAAC;IAAE,IAAI,EAAE,MAAM,CAAA;CAAE,CAAC,CAAC,CAsBlC;AAED;;;;GAIG;AACH,wBAAgB,yBAAyB,CAAC,KAAK,EAAE,YAAY,EAAE,OAAO,CAAC,EAAE,MAAM,GAAG,MAAM,GAAG,SAAS,CAUnG"}
|
|
@@ -9,8 +9,11 @@
|
|
|
9
9
|
*
|
|
10
10
|
* - `data` present -> `data:<mimeType>;base64,<data>` (unchanged, legacy safe)
|
|
11
11
|
* - `url` public -> passed through as-is (no base64 in the context!)
|
|
12
|
+
* - `url` backend-hosted -> swapped for a presigned, headerless-fetchable
|
|
13
|
+
* url (`/generated-images/<id>` is what the
|
|
14
|
+
* backend returns; it is AUTH-GATED, so handing
|
|
15
|
+
* it to a provider only yields a 401)
|
|
12
16
|
* - `url` relative -> resolved against the backend base url first
|
|
13
|
-
* (`/generated-images/<id>` is what the backend returns)
|
|
14
17
|
* - `url` local/private -> fetched and inlined **at send time only**
|
|
15
18
|
*
|
|
16
19
|
* The inlining is deliberately scoped to a single request: the fetched bytes
|
|
@@ -19,6 +22,7 @@
|
|
|
19
22
|
*
|
|
20
23
|
* @module @spectral/ai/utils/image-source
|
|
21
24
|
*/
|
|
25
|
+
import { hostedImageIdFromUrl, isHostedImageUrl } from "../../../backend/generated-images.js";
|
|
22
26
|
/** Don't inline absurd payloads — 25 MiB of base64 would blow the request. */
|
|
23
27
|
export const DEFAULT_MAX_INLINE_BYTES = 25 * 1024 * 1024;
|
|
24
28
|
/** Timeout for fetching a local image that has to be inlined. */
|
|
@@ -170,6 +174,44 @@ async function resolveBaseUrlOption(baseUrl) {
|
|
|
170
174
|
return undefined;
|
|
171
175
|
}
|
|
172
176
|
}
|
|
177
|
+
/**
|
|
178
|
+
* True for a backend-hosted image reference, either backend-relative
|
|
179
|
+
* (`/generated-images/<id>`) or absolute
|
|
180
|
+
* (`https://api.aexol.ai/generated-images/<id>`).
|
|
181
|
+
*
|
|
182
|
+
* `backendUrl` is REQUIRED to accept an ABSOLUTE url: without it there is no
|
|
183
|
+
* origin to compare against, and a foreign host that happens to expose
|
|
184
|
+
* `/generated-images/<id>` must never be treated as ours (that would send the
|
|
185
|
+
* machine JWT to it, and would let it swap in a presigned url of one of our
|
|
186
|
+
* tenant's images). Relative urls are always accepted — they can only ever be
|
|
187
|
+
* resolved against the backend base url.
|
|
188
|
+
*
|
|
189
|
+
* Those endpoints are auth-gated: a provider fetching them without an
|
|
190
|
+
* `Authorization` header gets a 401 and never sees the image.
|
|
191
|
+
*/
|
|
192
|
+
export function isHostedBackendImageUrl(rawUrl, backendUrl) {
|
|
193
|
+
return isHostedImageUrl(rawUrl, backendUrl ?? undefined);
|
|
194
|
+
}
|
|
195
|
+
/** Resolve (and normalize) the lazily supplied auth headers. */
|
|
196
|
+
async function resolveAuthHeadersOption(authHeaders) {
|
|
197
|
+
if (!authHeaders)
|
|
198
|
+
return undefined;
|
|
199
|
+
let headers;
|
|
200
|
+
if (typeof authHeaders === "function") {
|
|
201
|
+
try {
|
|
202
|
+
headers = await authHeaders();
|
|
203
|
+
}
|
|
204
|
+
catch {
|
|
205
|
+
return undefined;
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
else {
|
|
209
|
+
headers = authHeaders;
|
|
210
|
+
}
|
|
211
|
+
if (!headers || Object.keys(headers).length === 0)
|
|
212
|
+
return undefined;
|
|
213
|
+
return headers;
|
|
214
|
+
}
|
|
173
215
|
export function hasInlineImageData(image) {
|
|
174
216
|
return typeof image.data === "string" && image.data.length > 0;
|
|
175
217
|
}
|
|
@@ -230,14 +272,46 @@ export async function resolveImageForProvider(image, options = {}) {
|
|
|
230
272
|
let url = typeof image.url === "string" ? image.url.trim() : "";
|
|
231
273
|
if (!url)
|
|
232
274
|
return null;
|
|
275
|
+
// Resolve the backend base url lazily, and ONLY when the url even looks
|
|
276
|
+
// hosted (path-wise): public cdn urls must not pay for a config/machine
|
|
277
|
+
// store read. The base url doubles as the origin an absolute url has to
|
|
278
|
+
// match before it is accepted as ours.
|
|
279
|
+
const looksHosted = hostedImageIdFromUrl(url) !== null;
|
|
280
|
+
const backendUrl = looksHosted ? await resolveBaseUrlOption(options.baseUrl) : undefined;
|
|
233
281
|
// Relative urls (`/generated-images/<id>`) must be joined with the backend
|
|
234
282
|
// base url before anything else — otherwise the provider gets a path.
|
|
235
283
|
if (isRelativeImageUrl(url)) {
|
|
236
|
-
url = resolveImageUrlAgainstBase(url,
|
|
284
|
+
url = resolveImageUrlAgainstBase(url, backendUrl);
|
|
285
|
+
}
|
|
286
|
+
// A hosted url points at OUR backend, which authenticates every request:
|
|
287
|
+
// the provider would fetch it anonymously and get a 401. Swap it for a
|
|
288
|
+
// presigned url first (that one needs no headers at all).
|
|
289
|
+
const hosted = isHostedBackendImageUrl(url, backendUrl);
|
|
290
|
+
if (hosted && options.resolveHostedUrl) {
|
|
291
|
+
try {
|
|
292
|
+
const resolvedUrl = (await options.resolveHostedUrl(url, { signal: options.signal, timeoutMs: options.timeoutMs }))?.trim();
|
|
293
|
+
if (resolvedUrl) {
|
|
294
|
+
return { url: resolvedUrl, inlined: false, mimeType: image.mimeType, hostedResolved: true };
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
catch (error) {
|
|
298
|
+
const reason = error instanceof Error ? error.message : String(error);
|
|
299
|
+
options.onWarn?.(`Could not resolve hosted image ${url} (${reason})`);
|
|
300
|
+
}
|
|
237
301
|
}
|
|
238
|
-
|
|
302
|
+
// Only inline a hosted url when we can actually authenticate the download;
|
|
303
|
+
// without credentials the fetch is a guaranteed 401 anyway, so the plain
|
|
304
|
+
// pass-through (identical end result, one request less) is kept.
|
|
305
|
+
const authHeaders = hosted ? await resolveAuthHeadersOption(options.authHeaders) : undefined;
|
|
306
|
+
// Belt and braces: re-check the origin here, because `url` was rewritten
|
|
307
|
+
// above (relative -> absolute) and only a url that is still ours may carry
|
|
308
|
+
// credentials. The resolver refuses foreign origins too, this is the last
|
|
309
|
+
// gate before the token goes on the wire.
|
|
310
|
+
const authenticated = authHeaders !== undefined && isHostedBackendImageUrl(url, backendUrl);
|
|
311
|
+
if (!isLocalOrPrivateImageUrl(url) && !authenticated) {
|
|
239
312
|
return { url, inlined: false, mimeType: image.mimeType };
|
|
240
313
|
}
|
|
314
|
+
const requestHeaders = authenticated ? authHeaders : undefined;
|
|
241
315
|
const doFetch = options.fetchImpl ?? fetch;
|
|
242
316
|
const timeoutMs = options.timeoutMs ?? DEFAULT_INLINE_FETCH_TIMEOUT_MS;
|
|
243
317
|
const maxInlineBytes = options.maxInlineBytes ?? DEFAULT_MAX_INLINE_BYTES;
|
|
@@ -249,7 +323,10 @@ export async function resolveImageForProvider(image, options = {}) {
|
|
|
249
323
|
// An already-aborted signal never fires the listener above.
|
|
250
324
|
if (options.signal?.aborted)
|
|
251
325
|
throw new Error("aborted");
|
|
252
|
-
|
|
326
|
+
// `requestHeaders` is only set when the (possibly rewritten) url still
|
|
327
|
+
// resolves to the backend origin — a url on any other host is fetched
|
|
328
|
+
// anonymously, so the JWT cannot leak to a third party.
|
|
329
|
+
const response = await doFetch(url, requestHeaders ? { signal: controller.signal, headers: requestHeaders } : { signal: controller.signal });
|
|
253
330
|
if (!response.ok)
|
|
254
331
|
throw new Error(`HTTP ${response.status}`);
|
|
255
332
|
// Reject oversized payloads from the declared length BEFORE reading
|
|
@@ -276,7 +353,7 @@ export async function resolveImageForProvider(image, options = {}) {
|
|
|
276
353
|
}
|
|
277
354
|
catch (error) {
|
|
278
355
|
const reason = error instanceof Error ? error.message : String(error);
|
|
279
|
-
options.onWarn?.(`Could not inline local image ${url} (${reason}); sending url as-is`);
|
|
356
|
+
options.onWarn?.(`Could not inline local image ${url} (${reason}); sending url as-is${hosted ? " — the provider may not be able to fetch it (auth required)" : ""}`);
|
|
280
357
|
return { url, inlined: false, mimeType: image.mimeType };
|
|
281
358
|
}
|
|
282
359
|
finally {
|