dsh-coding-subscription-oauth 0.5.0
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/CHANGELOG.md +136 -0
- package/CONTRIBUTING.md +109 -0
- package/INSTALL.md +220 -0
- package/LICENSE +19 -0
- package/NOTICE +11 -0
- package/README.de.md +254 -0
- package/README.es.md +254 -0
- package/README.fr.md +254 -0
- package/README.ja.md +254 -0
- package/README.ko.md +254 -0
- package/README.md +286 -0
- package/README.pt-BR.md +254 -0
- package/README.ru.md +254 -0
- package/README.zh-CN.md +271 -0
- package/cordis.patch.yml +13 -0
- package/docs/00-project-rules.md +195 -0
- package/docs/02-architecture.md +132 -0
- package/docs/02-architecture.zh-CN.md +128 -0
- package/lib/adapter.d.ts +24 -0
- package/lib/adapter.d.ts.map +1 -0
- package/lib/alias-adapter.d.ts +43 -0
- package/lib/alias-adapter.d.ts.map +1 -0
- package/lib/auth-routes.d.ts +155 -0
- package/lib/auth-routes.d.ts.map +1 -0
- package/lib/auth.d.ts +29 -0
- package/lib/auth.d.ts.map +1 -0
- package/lib/bin.d.ts +6 -0
- package/lib/bin.d.ts.map +1 -0
- package/lib/bin.js +27591 -0
- package/lib/bin.js.map +7 -0
- package/lib/capability-routes.d.ts +43 -0
- package/lib/capability-routes.d.ts.map +1 -0
- package/lib/capability-runtime.d.ts +56 -0
- package/lib/capability-runtime.d.ts.map +1 -0
- package/lib/capability-settings.d.ts +263 -0
- package/lib/capability-settings.d.ts.map +1 -0
- package/lib/capability-tools.d.ts +50 -0
- package/lib/capability-tools.d.ts.map +1 -0
- package/lib/catalog.d.ts +53 -0
- package/lib/catalog.d.ts.map +1 -0
- package/lib/client.js +3 -0
- package/lib/client.js.map +7 -0
- package/lib/codex-http.d.ts +77 -0
- package/lib/codex-http.d.ts.map +1 -0
- package/lib/codex-images.d.ts +109 -0
- package/lib/codex-images.d.ts.map +1 -0
- package/lib/codex-model-capabilities.d.ts +112 -0
- package/lib/codex-model-capabilities.d.ts.map +1 -0
- package/lib/codex-search.d.ts +96 -0
- package/lib/codex-search.d.ts.map +1 -0
- package/lib/codex-usage.d.ts +79 -0
- package/lib/codex-usage.d.ts.map +1 -0
- package/lib/gateway-anthropic-messages.d.ts +8 -0
- package/lib/gateway-anthropic-messages.d.ts.map +1 -0
- package/lib/gateway-auth.d.ts +22 -0
- package/lib/gateway-auth.d.ts.map +1 -0
- package/lib/gateway-backend.d.ts +62 -0
- package/lib/gateway-backend.d.ts.map +1 -0
- package/lib/gateway-body.d.ts +9 -0
- package/lib/gateway-body.d.ts.map +1 -0
- package/lib/gateway-config.d.ts +24 -0
- package/lib/gateway-config.d.ts.map +1 -0
- package/lib/gateway-http.d.ts +16 -0
- package/lib/gateway-http.d.ts.map +1 -0
- package/lib/gateway-openai-chat.d.ts +8 -0
- package/lib/gateway-openai-chat.d.ts.map +1 -0
- package/lib/gateway-openai-responses.d.ts +8 -0
- package/lib/gateway-openai-responses.d.ts.map +1 -0
- package/lib/gateway-parse.d.ts +10 -0
- package/lib/gateway-parse.d.ts.map +1 -0
- package/lib/gateway-protocol.d.ts +47 -0
- package/lib/gateway-protocol.d.ts.map +1 -0
- package/lib/gateway-routes.d.ts +21 -0
- package/lib/gateway-routes.d.ts.map +1 -0
- package/lib/gateway.d.ts +48 -0
- package/lib/gateway.d.ts.map +1 -0
- package/lib/grok-imagine.d.ts +271 -0
- package/lib/grok-imagine.d.ts.map +1 -0
- package/lib/grok-import.d.ts +21 -0
- package/lib/grok-import.d.ts.map +1 -0
- package/lib/http-json.d.ts +10 -0
- package/lib/http-json.d.ts.map +1 -0
- package/lib/ids.d.ts +33 -0
- package/lib/ids.d.ts.map +1 -0
- package/lib/imagine-routes.d.ts +59 -0
- package/lib/imagine-routes.d.ts.map +1 -0
- package/lib/index.d.ts +69 -0
- package/lib/index.d.ts.map +1 -0
- package/lib/index.js +35355 -0
- package/lib/index.js.map +7 -0
- package/lib/invariant.d.ts +9 -0
- package/lib/invariant.d.ts.map +1 -0
- package/lib/invariant.js +14 -0
- package/lib/invariant.js.map +7 -0
- package/lib/kimi-errors.d.ts +13 -0
- package/lib/kimi-errors.d.ts.map +1 -0
- package/lib/media-store.d.ts +130 -0
- package/lib/media-store.d.ts.map +1 -0
- package/lib/oauth-import-routes.d.ts +52 -0
- package/lib/oauth-import-routes.d.ts.map +1 -0
- package/lib/oauth-providers.d.ts +26 -0
- package/lib/oauth-providers.d.ts.map +1 -0
- package/lib/oauth-session.d.ts +40 -0
- package/lib/oauth-session.d.ts.map +1 -0
- package/lib/oauth-sources.d.ts +205 -0
- package/lib/oauth-sources.d.ts.map +1 -0
- package/lib/oauth.d.ts +79 -0
- package/lib/oauth.d.ts.map +1 -0
- package/lib/provider.d.ts +38 -0
- package/lib/provider.d.ts.map +1 -0
- package/lib/proxy.d.ts +17 -0
- package/lib/proxy.d.ts.map +1 -0
- package/lib/redact.d.ts +5 -0
- package/lib/redact.d.ts.map +1 -0
- package/lib/session.d.ts +40 -0
- package/lib/session.d.ts.map +1 -0
- package/lib/store.d.ts +46 -0
- package/lib/store.d.ts.map +1 -0
- package/lib/web-origin.d.ts +10 -0
- package/lib/web-origin.d.ts.map +1 -0
- package/lib/web-routes.d.ts +20 -0
- package/lib/web-routes.d.ts.map +1 -0
- package/package.json +185 -0
- package/patches/dsh-agy@0.1.2.patch +25 -0
- package/scripts/release.mjs +166 -0
- package/scripts/smoke-deployed-routes.mjs +146 -0
- package/scripts/verify-deployed-catalog.mjs +87 -0
- package/src/adapter.ts +282 -0
- package/src/alias-adapter.ts +152 -0
- package/src/auth-routes.ts +871 -0
- package/src/auth.ts +67 -0
- package/src/bin.ts +350 -0
- package/src/capability-routes.ts +275 -0
- package/src/capability-runtime.ts +313 -0
- package/src/capability-settings.ts +657 -0
- package/src/capability-tools.ts +666 -0
- package/src/catalog.ts +271 -0
- package/src/client/GrokBuildSettings.tsx +2221 -0
- package/src/client/index.tsx +37 -0
- package/src/client/locales.ts +421 -0
- package/src/codex-http.ts +447 -0
- package/src/codex-images.ts +485 -0
- package/src/codex-model-capabilities.ts +320 -0
- package/src/codex-search.ts +245 -0
- package/src/codex-usage.ts +263 -0
- package/src/gateway-anthropic-messages.ts +84 -0
- package/src/gateway-auth.ts +100 -0
- package/src/gateway-backend.ts +274 -0
- package/src/gateway-body.ts +49 -0
- package/src/gateway-config.ts +76 -0
- package/src/gateway-http.ts +104 -0
- package/src/gateway-openai-chat.ts +124 -0
- package/src/gateway-openai-responses.ts +53 -0
- package/src/gateway-parse.ts +224 -0
- package/src/gateway-protocol.ts +52 -0
- package/src/gateway-routes.ts +152 -0
- package/src/gateway.ts +242 -0
- package/src/grok-imagine.ts +1627 -0
- package/src/grok-import.ts +151 -0
- package/src/http-json.ts +82 -0
- package/src/ids.ts +45 -0
- package/src/imagine-routes.ts +461 -0
- package/src/index.ts +598 -0
- package/src/invariant.ts +17 -0
- package/src/kimi-errors.ts +26 -0
- package/src/media-store.ts +927 -0
- package/src/oauth-import-routes.ts +314 -0
- package/src/oauth-providers.ts +152 -0
- package/src/oauth-session.ts +183 -0
- package/src/oauth-sources.ts +1104 -0
- package/src/oauth.ts +620 -0
- package/src/provider.ts +128 -0
- package/src/proxy.ts +99 -0
- package/src/redact.ts +72 -0
- package/src/session.ts +218 -0
- package/src/store.ts +217 -0
- package/src/web-origin.ts +60 -0
- package/src/web-routes.ts +75 -0
|
@@ -0,0 +1,1627 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Official xAI Imagine client. Talks only to https://api.x.ai with a
|
|
3
|
+
* per-operation API-key resolver. Generated remote media is fetched under a
|
|
4
|
+
* pinned HTTPS/host/DNS allowlist and persisted locally; results never include
|
|
5
|
+
* a signed upstream URL.
|
|
6
|
+
* @module dsh-coding-subscription-oauth/grok-imagine
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { Buffer } from "node:buffer";
|
|
10
|
+
import { lookup as dnsLookup } from "node:dns/promises";
|
|
11
|
+
import { BlockList, isIP } from "node:net";
|
|
12
|
+
import { Agent, fetch as undiciFetch } from "undici";
|
|
13
|
+
import {
|
|
14
|
+
assertTrustedImagineAuthz,
|
|
15
|
+
IMAGINE_MEDIA_ROUTE_PREFIX,
|
|
16
|
+
imagineMediaPath,
|
|
17
|
+
isTrustedImaginePeer,
|
|
18
|
+
type MediaArtifactMeta,
|
|
19
|
+
type MediaStore,
|
|
20
|
+
type MediaStoreVideoType,
|
|
21
|
+
type TrustedImagineAuthz,
|
|
22
|
+
} from "./media-store.ts";
|
|
23
|
+
import { safeMessage } from "./redact.ts";
|
|
24
|
+
|
|
25
|
+
export const XAI_API_ORIGIN = "https://api.x.ai";
|
|
26
|
+
export const GROK_IMAGINE_IMAGE_PATH = "/v1/images/generations";
|
|
27
|
+
export const GROK_IMAGINE_VIDEO_START_PATH = "/v1/videos/generations";
|
|
28
|
+
export const GROK_IMAGINE_IMAGE_MODEL = "grok-imagine-image-2.0";
|
|
29
|
+
export const GROK_IMAGINE_VIDEO_MODEL = "grok-imagine-video-1.5";
|
|
30
|
+
|
|
31
|
+
export const GROK_IMAGINE_IMAGE_TOOL = "grok_imagine_image";
|
|
32
|
+
export const GROK_IMAGINE_VIDEO_TOOL = "grok_imagine_video";
|
|
33
|
+
export const GROK_IMAGINE_VIDEO_STATUS_TOOL = "grok_imagine_video_status";
|
|
34
|
+
|
|
35
|
+
export const IMAGINE_IMAGE_ROUTE_PREFIX = "/plugins/dsh-grok-build/imagine/images/";
|
|
36
|
+
|
|
37
|
+
/** Frozen xAI output hosts. Callers cannot widen this list. */
|
|
38
|
+
export const XAI_OUTPUT_HOSTS = ["imgen.x.ai", "videogen.x.ai", "vidgen.x.ai"] as const;
|
|
39
|
+
|
|
40
|
+
export const IMAGINE_IMAGE_RESOLUTIONS = ["1k", "2k"] as const;
|
|
41
|
+
export const IMAGINE_VIDEO_RESOLUTIONS = ["480p", "720p", "1080p"] as const;
|
|
42
|
+
export const IMAGINE_IMAGE_ASPECT_RATIOS = [
|
|
43
|
+
"1:1",
|
|
44
|
+
"3:4",
|
|
45
|
+
"4:3",
|
|
46
|
+
"9:16",
|
|
47
|
+
"16:9",
|
|
48
|
+
"2:3",
|
|
49
|
+
"3:2",
|
|
50
|
+
"9:19.5",
|
|
51
|
+
"19.5:9",
|
|
52
|
+
"9:20",
|
|
53
|
+
"20:9",
|
|
54
|
+
"1:2",
|
|
55
|
+
"2:1",
|
|
56
|
+
"auto",
|
|
57
|
+
] as const;
|
|
58
|
+
export const IMAGINE_VIDEO_ASPECT_RATIOS = ["1:1", "16:9", "9:16", "4:3", "3:4", "3:2", "2:3"] as const;
|
|
59
|
+
export const IMAGINE_VIDEO_MIN_DURATION_SECONDS = 1;
|
|
60
|
+
export const IMAGINE_VIDEO_MAX_DURATION_SECONDS = 15;
|
|
61
|
+
export const IMAGINE_IMAGE_MAX_N = 10;
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Hard ceilings enforced both at the tool boundary (consumer guard) and inside
|
|
65
|
+
* the Imagine client (defence-in-depth). The prompt ceiling uses UTF-16 code
|
|
66
|
+
* units because that is what the JSON wire format bounds; the image-id arrays
|
|
67
|
+
* are bounded so a runaway caller cannot exhaust the bounded body reader.
|
|
68
|
+
*/
|
|
69
|
+
export const IMAGINE_PROMPT_MAX_LENGTH = 4000;
|
|
70
|
+
export const IMAGINE_IMAGE_IDS_MIN = 1;
|
|
71
|
+
export const IMAGINE_IMAGE_IDS_MAX = 5;
|
|
72
|
+
|
|
73
|
+
export const DEFAULT_IMAGE_DOWNLOAD_MAX_BYTES = 5 * 1024 * 1024;
|
|
74
|
+
export const DEFAULT_API_TIMEOUT_MS = 30_000;
|
|
75
|
+
export const DEFAULT_IMAGE_DOWNLOAD_TIMEOUT_MS = 15_000;
|
|
76
|
+
export const DEFAULT_VIDEO_DOWNLOAD_TIMEOUT_MS = 60_000;
|
|
77
|
+
export const DEFAULT_MAX_REDIRECTS = 3;
|
|
78
|
+
export const DEFAULT_API_JSON_MAX_BYTES = 1024 * 1024;
|
|
79
|
+
export const MAX_CACHED_VIDEO_JOBS = 256;
|
|
80
|
+
|
|
81
|
+
export type ImagineOperation = "image.generate" | "video.start" | "video.status";
|
|
82
|
+
export type ImagineImageResolution = (typeof IMAGINE_IMAGE_RESOLUTIONS)[number];
|
|
83
|
+
export type ImagineVideoResolution = (typeof IMAGINE_VIDEO_RESOLUTIONS)[number];
|
|
84
|
+
export type ImagineImageAspectRatio = (typeof IMAGINE_IMAGE_ASPECT_RATIOS)[number];
|
|
85
|
+
export type ImagineVideoAspectRatio = (typeof IMAGINE_VIDEO_ASPECT_RATIOS)[number];
|
|
86
|
+
|
|
87
|
+
export type ImagineApiKeyResolver = (operation: ImagineOperation) => Promise<string>;
|
|
88
|
+
|
|
89
|
+
export type ImagineFetch = (input: string | URL, init?: RequestInit) => Promise<Response>;
|
|
90
|
+
|
|
91
|
+
export type ImagineDnsLookup = (hostname: string) => Promise<readonly string[]>;
|
|
92
|
+
|
|
93
|
+
export type ImagineImageMediaType = "image/png" | "image/jpeg" | "image/webp" | "image/gif";
|
|
94
|
+
|
|
95
|
+
export interface ImagineMediaHopRequest {
|
|
96
|
+
timeoutMs: number;
|
|
97
|
+
maxBytes: number;
|
|
98
|
+
accept: string;
|
|
99
|
+
signal?: AbortSignal;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export interface ImagineMediaHop {
|
|
103
|
+
status: number;
|
|
104
|
+
location?: string;
|
|
105
|
+
contentType?: string;
|
|
106
|
+
data?: Uint8Array;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** Single-hop GET. Must not follow redirects or attach credentials. */
|
|
110
|
+
export interface ImagineMediaTransport {
|
|
111
|
+
get(url: URL, request: ImagineMediaHopRequest): Promise<ImagineMediaHop>;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
export interface ImagineDownloadRequest {
|
|
115
|
+
url: string;
|
|
116
|
+
timeoutMs: number;
|
|
117
|
+
maxBytes: number;
|
|
118
|
+
accept: string;
|
|
119
|
+
signal?: AbortSignal;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
export interface ImagineDownloadResult {
|
|
123
|
+
data: Uint8Array;
|
|
124
|
+
contentType?: string;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/** Full download including redirect revalidation. Production default is pinned. */
|
|
128
|
+
export interface ImagineDownloader {
|
|
129
|
+
download(request: ImagineDownloadRequest): Promise<ImagineDownloadResult>;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
export type GrokImagineErrorCode =
|
|
133
|
+
| "MISSING_CREDENTIAL"
|
|
134
|
+
| "AUTH"
|
|
135
|
+
| "QUOTA"
|
|
136
|
+
| "RATE_LIMIT"
|
|
137
|
+
| "SSRF"
|
|
138
|
+
| "INVALID_INPUT"
|
|
139
|
+
| "UPSTREAM"
|
|
140
|
+
| "TIMEOUT"
|
|
141
|
+
| "MEDIA";
|
|
142
|
+
|
|
143
|
+
export class GrokImagineError extends Error {
|
|
144
|
+
readonly code: GrokImagineErrorCode;
|
|
145
|
+
readonly status?: number;
|
|
146
|
+
|
|
147
|
+
constructor(code: GrokImagineErrorCode, message: string, options?: { cause?: unknown; status?: number }) {
|
|
148
|
+
super(message, options);
|
|
149
|
+
this.name = "GrokImagineError";
|
|
150
|
+
this.code = code;
|
|
151
|
+
if (options?.status !== undefined) this.status = options.status;
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
export interface ImagineImageAttachmentRef {
|
|
156
|
+
attachmentId: string;
|
|
157
|
+
mediaType: ImagineImageMediaType;
|
|
158
|
+
bytes: number;
|
|
159
|
+
width: number;
|
|
160
|
+
height: number;
|
|
161
|
+
name?: string;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
export interface ImagineImageLimits {
|
|
165
|
+
maxImageBytes?: number;
|
|
166
|
+
mediaTypes?: readonly string[];
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/** Structural AttachmentStore surface used by Imagine. */
|
|
170
|
+
export interface ImagineAttachmentStore {
|
|
171
|
+
imageLimits?: ImagineImageLimits;
|
|
172
|
+
saveImage(input: {
|
|
173
|
+
data: Uint8Array;
|
|
174
|
+
mediaType: ImagineImageMediaType;
|
|
175
|
+
name?: string;
|
|
176
|
+
}): Promise<ImagineImageAttachmentRef>;
|
|
177
|
+
readImage?(
|
|
178
|
+
ref: ImagineImageAttachmentRef,
|
|
179
|
+
signal?: AbortSignal,
|
|
180
|
+
): Promise<{ ref: ImagineImageAttachmentRef; data: Uint8Array }>;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
export interface GenerateImagineImageInput {
|
|
184
|
+
prompt: string;
|
|
185
|
+
model?: string;
|
|
186
|
+
n?: number;
|
|
187
|
+
aspectRatio?: string;
|
|
188
|
+
resolution?: string;
|
|
189
|
+
name?: string;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
export interface ImagineImageResult {
|
|
193
|
+
model: string;
|
|
194
|
+
images: readonly ImaginePersistedImage[];
|
|
195
|
+
attachment: ImagineImageAttachmentRef;
|
|
196
|
+
path: string;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
export interface ImaginePersistedImage {
|
|
200
|
+
attachment: ImagineImageAttachmentRef;
|
|
201
|
+
path: string;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
export interface StartImagineVideoInput {
|
|
205
|
+
prompt: string;
|
|
206
|
+
model?: string;
|
|
207
|
+
duration?: number;
|
|
208
|
+
aspectRatio?: string;
|
|
209
|
+
resolution?: string;
|
|
210
|
+
name?: string;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
export interface ImagineVideoStartResult {
|
|
214
|
+
model: string;
|
|
215
|
+
requestId: string;
|
|
216
|
+
status: "pending";
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
export type ImagineVideoJobStatus = "pending" | "completed" | "failed";
|
|
220
|
+
|
|
221
|
+
export interface ImagineVideoStatusResult {
|
|
222
|
+
requestId: string;
|
|
223
|
+
status: ImagineVideoJobStatus;
|
|
224
|
+
artifact?: MediaArtifactMeta;
|
|
225
|
+
path?: string;
|
|
226
|
+
error?: string;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
export interface GrokImagineClientOptions {
|
|
230
|
+
resolveApiKey: ImagineApiKeyResolver;
|
|
231
|
+
attachments: ImagineAttachmentStore;
|
|
232
|
+
media: MediaStore;
|
|
233
|
+
/** API-only test seam. Never used for media and never a production default. */
|
|
234
|
+
fetch?: ImagineFetch;
|
|
235
|
+
/** Media downloads. Production default pins DNS at connect time via undici. */
|
|
236
|
+
downloader?: ImagineDownloader;
|
|
237
|
+
now?: () => number;
|
|
238
|
+
imageDownloadTimeoutMs?: number;
|
|
239
|
+
videoDownloadTimeoutMs?: number;
|
|
240
|
+
apiTimeoutMs?: number;
|
|
241
|
+
maxRedirects?: number;
|
|
242
|
+
imageMaxBytes?: number;
|
|
243
|
+
apiJsonMaxBytes?: number;
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
export interface ImagineImageDownloadHeaders {
|
|
247
|
+
"Content-Type": string;
|
|
248
|
+
"Content-Length": string;
|
|
249
|
+
"Content-Disposition": string;
|
|
250
|
+
"Cache-Control": string;
|
|
251
|
+
"X-Content-Type-Options": "nosniff";
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
export interface ImagineImageDownloadView {
|
|
255
|
+
ref: ImagineImageAttachmentRef;
|
|
256
|
+
body: Uint8Array;
|
|
257
|
+
headers: ImagineImageDownloadHeaders;
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
/**
|
|
261
|
+
* Safe official-xAI request-id pattern: ASCII letters, digits, dashes, and
|
|
262
|
+
* underscores. Limited to 256 characters. Public boundary validator.
|
|
263
|
+
*/
|
|
264
|
+
const VIDEO_REQUEST_ID_PATTERN = /^[A-Za-z0-9_-]{1,256}$/;
|
|
265
|
+
const FROZEN_OUTPUT_HOSTS = new Set<string>(XAI_OUTPUT_HOSTS);
|
|
266
|
+
const IMAGE_RESOLUTION_SET = new Set<string>(IMAGINE_IMAGE_RESOLUTIONS);
|
|
267
|
+
const VIDEO_RESOLUTION_SET = new Set<string>(IMAGINE_VIDEO_RESOLUTIONS);
|
|
268
|
+
const IMAGE_ASPECT_SET = new Set<string>(IMAGINE_IMAGE_ASPECT_RATIOS);
|
|
269
|
+
const VIDEO_ASPECT_SET = new Set<string>(IMAGINE_VIDEO_ASPECT_RATIOS);
|
|
270
|
+
|
|
271
|
+
const blockedNetworks = new BlockList();
|
|
272
|
+
blockedNetworks.addSubnet("0.0.0.0", 8, "ipv4");
|
|
273
|
+
blockedNetworks.addSubnet("10.0.0.0", 8, "ipv4");
|
|
274
|
+
blockedNetworks.addSubnet("100.64.0.0", 10, "ipv4");
|
|
275
|
+
blockedNetworks.addSubnet("127.0.0.0", 8, "ipv4");
|
|
276
|
+
blockedNetworks.addSubnet("169.254.0.0", 16, "ipv4");
|
|
277
|
+
blockedNetworks.addSubnet("172.16.0.0", 12, "ipv4");
|
|
278
|
+
blockedNetworks.addSubnet("192.0.0.0", 24, "ipv4");
|
|
279
|
+
blockedNetworks.addSubnet("192.0.2.0", 24, "ipv4");
|
|
280
|
+
blockedNetworks.addSubnet("192.88.99.0", 24, "ipv4");
|
|
281
|
+
blockedNetworks.addSubnet("192.168.0.0", 16, "ipv4");
|
|
282
|
+
blockedNetworks.addSubnet("198.18.0.0", 15, "ipv4");
|
|
283
|
+
blockedNetworks.addSubnet("198.51.100.0", 24, "ipv4");
|
|
284
|
+
blockedNetworks.addSubnet("203.0.113.0", 24, "ipv4");
|
|
285
|
+
blockedNetworks.addSubnet("224.0.0.0", 4, "ipv4");
|
|
286
|
+
blockedNetworks.addSubnet("240.0.0.0", 4, "ipv4");
|
|
287
|
+
blockedNetworks.addSubnet("::", 128, "ipv6");
|
|
288
|
+
blockedNetworks.addSubnet("::1", 128, "ipv6");
|
|
289
|
+
blockedNetworks.addSubnet("::", 96, "ipv6");
|
|
290
|
+
blockedNetworks.addSubnet("64:ff9b::", 96, "ipv6");
|
|
291
|
+
blockedNetworks.addSubnet("64:ff9b:1::", 48, "ipv6");
|
|
292
|
+
blockedNetworks.addSubnet("100::", 64, "ipv6");
|
|
293
|
+
blockedNetworks.addSubnet("2001::", 32, "ipv6");
|
|
294
|
+
blockedNetworks.addSubnet("2001:2::", 48, "ipv6");
|
|
295
|
+
blockedNetworks.addSubnet("2001:db8::", 32, "ipv6");
|
|
296
|
+
blockedNetworks.addSubnet("2002::", 16, "ipv6");
|
|
297
|
+
blockedNetworks.addSubnet("3fff::", 20, "ipv6");
|
|
298
|
+
blockedNetworks.addSubnet("5f00::", 16, "ipv6");
|
|
299
|
+
blockedNetworks.addSubnet("fc00::", 7, "ipv6");
|
|
300
|
+
blockedNetworks.addSubnet("fe80::", 10, "ipv6");
|
|
301
|
+
blockedNetworks.addSubnet("ff00::", 8, "ipv6");
|
|
302
|
+
|
|
303
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
304
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
export function redactImagineMessage(error: unknown): string {
|
|
308
|
+
const withoutRemoteUrls = (error instanceof Error ? error.message : String(error)).replace(
|
|
309
|
+
/https?:\/\/[^\s"'<>\\]+/giu,
|
|
310
|
+
"[redacted-url]",
|
|
311
|
+
);
|
|
312
|
+
return safeMessage(withoutRemoteUrls);
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
function dottedFromWords(high: number, low: number): string {
|
|
316
|
+
return `${(high >> 8) & 255}.${high & 255}.${(low >> 8) & 255}.${low & 255}`;
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
function parseIPv6Groups(address: string): number[] | undefined {
|
|
320
|
+
const bare = address.toLowerCase().split("%", 1)[0] ?? "";
|
|
321
|
+
let normalized = bare;
|
|
322
|
+
const v4tail = /:(\d{1,3}(?:\.\d{1,3}){3})$/u.exec(bare);
|
|
323
|
+
if (v4tail) {
|
|
324
|
+
const ipv4 = v4tail[1] ?? "";
|
|
325
|
+
if (isIP(ipv4) !== 4) return undefined;
|
|
326
|
+
const octets = ipv4.split(".").map((part) => Number(part));
|
|
327
|
+
if (octets.length !== 4 || octets.some((part) => !Number.isInteger(part) || part < 0 || part > 255)) {
|
|
328
|
+
return undefined;
|
|
329
|
+
}
|
|
330
|
+
const high = (((octets[0] ?? 0) << 8) | (octets[1] ?? 0)).toString(16);
|
|
331
|
+
const low = (((octets[2] ?? 0) << 8) | (octets[3] ?? 0)).toString(16);
|
|
332
|
+
normalized = `${bare.slice(0, v4tail.index + 1)}${high}:${low}`;
|
|
333
|
+
}
|
|
334
|
+
const sides = normalized.split("::");
|
|
335
|
+
if (sides.length > 2) return undefined;
|
|
336
|
+
const parseSide = (side: string | undefined): number[] | undefined => {
|
|
337
|
+
if (side === undefined || side === "") return [];
|
|
338
|
+
const groups = side.split(":").map((group) => Number.parseInt(group, 16));
|
|
339
|
+
if (groups.some((group) => !Number.isInteger(group) || group < 0 || group > 0xffff)) return undefined;
|
|
340
|
+
return groups;
|
|
341
|
+
};
|
|
342
|
+
if (sides.length === 1) {
|
|
343
|
+
const groups = parseSide(sides[0]);
|
|
344
|
+
return groups?.length === 8 ? groups : undefined;
|
|
345
|
+
}
|
|
346
|
+
const head = parseSide(sides[0]);
|
|
347
|
+
const tail = parseSide(sides[1]);
|
|
348
|
+
if (head === undefined || tail === undefined || head.length + tail.length > 8) return undefined;
|
|
349
|
+
return [...head, ...new Array<number>(8 - head.length - tail.length).fill(0), ...tail];
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
function embeddedIPv4s(address: string): string[] {
|
|
353
|
+
const groups = parseIPv6Groups(address);
|
|
354
|
+
if (groups === undefined) return [];
|
|
355
|
+
const found = new Set<string>();
|
|
356
|
+
const last32 = dottedFromWords(groups[6] ?? 0, groups[7] ?? 0);
|
|
357
|
+
const headZero = groups.slice(0, 5).every((group) => group === 0);
|
|
358
|
+
if (headZero && ((groups[5] ?? 0) === 0xffff || (groups[5] ?? 0) === 0)) {
|
|
359
|
+
found.add(last32);
|
|
360
|
+
}
|
|
361
|
+
if ((groups[0] ?? 0) === 0x2002) {
|
|
362
|
+
found.add(dottedFromWords(groups[1] ?? 0, groups[2] ?? 0));
|
|
363
|
+
}
|
|
364
|
+
if ((groups[0] ?? 0) === 0x2001 && (groups[1] ?? 0) === 0) {
|
|
365
|
+
found.add(dottedFromWords(groups[2] ?? 0, groups[3] ?? 0));
|
|
366
|
+
found.add(dottedFromWords((groups[6] ?? 0) ^ 0xffff, (groups[7] ?? 0) ^ 0xffff));
|
|
367
|
+
}
|
|
368
|
+
if ((groups[0] ?? 0) === 0x64 && (groups[1] ?? 0) === 0xff9b) {
|
|
369
|
+
found.add(last32);
|
|
370
|
+
}
|
|
371
|
+
return [...found];
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
function ipv4Mapped(address: string): string | undefined {
|
|
375
|
+
const lower = address.toLowerCase();
|
|
376
|
+
if (!lower.startsWith("::ffff:")) return undefined;
|
|
377
|
+
const rest = lower.slice("::ffff:".length);
|
|
378
|
+
if (isIP(rest) === 4) return rest;
|
|
379
|
+
const parts = rest.split(":");
|
|
380
|
+
if (parts.length !== 2) return undefined;
|
|
381
|
+
const high = Number.parseInt(parts[0] ?? "", 16);
|
|
382
|
+
const low = Number.parseInt(parts[1] ?? "", 16);
|
|
383
|
+
if (!Number.isInteger(high) || !Number.isInteger(low) || high < 0 || low < 0 || high > 0xffff || low > 0xffff) {
|
|
384
|
+
return undefined;
|
|
385
|
+
}
|
|
386
|
+
return dottedFromWords(high, low);
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
/** True when an address is loopback, private, reserved, documentation, or an embedded special form. */
|
|
390
|
+
export function isBlockedIp(address: string): boolean {
|
|
391
|
+
if (isIP(address) === 0) return true;
|
|
392
|
+
if (isIP(address) === 4) return blockedNetworks.check(address, "ipv4");
|
|
393
|
+
if (blockedNetworks.check(address, "ipv6")) return true;
|
|
394
|
+
if (parseIPv6Groups(address) === undefined) return true;
|
|
395
|
+
const mapped = ipv4Mapped(address);
|
|
396
|
+
if (mapped !== undefined && isBlockedIp(mapped)) return true;
|
|
397
|
+
for (const embedded of embeddedIPv4s(address)) {
|
|
398
|
+
if (isIP(embedded) === 4 && blockedNetworks.check(embedded, "ipv4")) return true;
|
|
399
|
+
}
|
|
400
|
+
return false;
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
function normalizeHostname(hostname: string): string {
|
|
404
|
+
return hostname.trim().toLowerCase().replace(/\.$/u, "");
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
function isBlockedHostname(hostname: string): boolean {
|
|
408
|
+
const host = normalizeHostname(hostname);
|
|
409
|
+
return (
|
|
410
|
+
host === "localhost" ||
|
|
411
|
+
host === "localhost.localdomain" ||
|
|
412
|
+
host.endsWith(".localhost") ||
|
|
413
|
+
host.endsWith(".local") ||
|
|
414
|
+
host.endsWith(".internal") ||
|
|
415
|
+
host.endsWith(".intranet")
|
|
416
|
+
);
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
export function isAllowlistedImagineHost(hostname: string): boolean {
|
|
420
|
+
return FROZEN_OUTPUT_HOSTS.has(normalizeHostname(hostname));
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
export function grokImagineVideoStatusPath(requestId: string): string {
|
|
424
|
+
if (!VIDEO_REQUEST_ID_PATTERN.test(requestId)) {
|
|
425
|
+
throw new GrokImagineError("INVALID_INPUT", "video request id is not a safe identifier");
|
|
426
|
+
}
|
|
427
|
+
return `/v1/videos/${encodeURIComponent(requestId)}`;
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
export function isSafeImagineAttachmentId(attachmentId: string): boolean {
|
|
431
|
+
if (attachmentId.length === 0 || attachmentId.length > 128) return false;
|
|
432
|
+
if (
|
|
433
|
+
attachmentId.includes("/") ||
|
|
434
|
+
attachmentId.includes("\\") ||
|
|
435
|
+
attachmentId.includes("..") ||
|
|
436
|
+
attachmentId.includes("\0")
|
|
437
|
+
) {
|
|
438
|
+
return false;
|
|
439
|
+
}
|
|
440
|
+
// DSH's local attachment backend currently emits `sha256:<hex>`; keep the
|
|
441
|
+
// host's opaque colon/dot-compatible contract while excluding separators.
|
|
442
|
+
return /^[A-Za-z0-9:._-]+$/u.test(attachmentId);
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
export function imagineImagePath(attachmentId: string): string {
|
|
446
|
+
if (!isSafeImagineAttachmentId(attachmentId)) {
|
|
447
|
+
throw new GrokImagineError("INVALID_INPUT", "attachment id is not safe for a same-origin image route");
|
|
448
|
+
}
|
|
449
|
+
return `${IMAGINE_IMAGE_ROUTE_PREFIX}${encodeURIComponent(attachmentId)}`;
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
export function parseImagineImagePath(pathname: string): string | undefined {
|
|
453
|
+
if (!pathname.startsWith(IMAGINE_IMAGE_ROUTE_PREFIX)) return undefined;
|
|
454
|
+
const rest = pathname.slice(IMAGINE_IMAGE_ROUTE_PREFIX.length);
|
|
455
|
+
if (rest.length === 0) return undefined;
|
|
456
|
+
if (rest.includes("/") || rest.includes("\\") || rest.includes("..")) return undefined;
|
|
457
|
+
if (rest.includes("\0")) return undefined;
|
|
458
|
+
let decoded: string;
|
|
459
|
+
try {
|
|
460
|
+
decoded = decodeURIComponent(rest);
|
|
461
|
+
} catch {
|
|
462
|
+
return undefined;
|
|
463
|
+
}
|
|
464
|
+
if (decoded.length === 0 || decoded.length > 128) return undefined;
|
|
465
|
+
return isSafeImagineAttachmentId(decoded) ? decoded : undefined;
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
const IMAGE_SUFFIX = new Set<string>(["png", "jpg", "jpeg", "webp", "gif"]);
|
|
469
|
+
const IMAGE_SUFFIX_RE = /\.([a-z0-9]{2,5})$/iu;
|
|
470
|
+
|
|
471
|
+
/**
|
|
472
|
+
* Build a `Content-Disposition` token for an Imagine image. Falls back to a
|
|
473
|
+
* percent-encoded `filename*=UTF-8''…` parameter when the legacy token would
|
|
474
|
+
* carry characters that would break RFC 6266 quoting.
|
|
475
|
+
*/
|
|
476
|
+
function imageContentDisposition(filename: string): string {
|
|
477
|
+
let safe = "";
|
|
478
|
+
let needsEncoding = false;
|
|
479
|
+
for (const character of filename) {
|
|
480
|
+
const codePoint = character.codePointAt(0) ?? 0;
|
|
481
|
+
if (
|
|
482
|
+
character === '"' ||
|
|
483
|
+
character === ";" ||
|
|
484
|
+
character === "\\" ||
|
|
485
|
+
character === "/" ||
|
|
486
|
+
codePoint < 0x20 ||
|
|
487
|
+
codePoint === 0x7f ||
|
|
488
|
+
codePoint >= 0x80
|
|
489
|
+
) {
|
|
490
|
+
needsEncoding = true;
|
|
491
|
+
continue;
|
|
492
|
+
}
|
|
493
|
+
safe = `${safe}${character}`;
|
|
494
|
+
}
|
|
495
|
+
safe = safe.trim().slice(0, 120);
|
|
496
|
+
if (!needsEncoding) return `inline; filename="${filename}"`;
|
|
497
|
+
const encoded = encodeURIComponent(filename).replace(
|
|
498
|
+
/[!'()*]/gu,
|
|
499
|
+
(character) => `%${character.charCodeAt(0).toString(16).toUpperCase().padStart(2, "0")}`,
|
|
500
|
+
);
|
|
501
|
+
return `inline; filename="${safe}"; filename*=UTF-8''${encoded}`;
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
export function imagineImageDownloadHeaders(ref: ImagineImageAttachmentRef): ImagineImageDownloadHeaders {
|
|
505
|
+
const subtype = ref.mediaType.slice("image/".length);
|
|
506
|
+
if (!IMAGE_SUFFIX.has(subtype)) {
|
|
507
|
+
throw new GrokImagineError("INVALID_INPUT", "imagine image subtype is not recognized for downloads");
|
|
508
|
+
}
|
|
509
|
+
const filename = `imagine-${ref.attachmentId}.${subtype}`;
|
|
510
|
+
if (!IMAGE_SUFFIX_RE.test(filename) || filename.length > 200) {
|
|
511
|
+
throw new GrokImagineError("INVALID_INPUT", "imagine image filename is not safe for downloads");
|
|
512
|
+
}
|
|
513
|
+
return {
|
|
514
|
+
"Content-Type": ref.mediaType,
|
|
515
|
+
"Content-Length": String(ref.bytes),
|
|
516
|
+
"Content-Disposition": imageContentDisposition(filename),
|
|
517
|
+
"Cache-Control": "private, max-age=0, no-store",
|
|
518
|
+
"X-Content-Type-Options": "nosniff",
|
|
519
|
+
};
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
export { IMAGINE_MEDIA_ROUTE_PREFIX, imagineMediaPath, isTrustedImaginePeer };
|
|
523
|
+
|
|
524
|
+
export function detectImageMediaType(data: Uint8Array): ImagineImageMediaType | undefined {
|
|
525
|
+
if (data.byteLength >= 8 && data[0] === 0x89 && data[1] === 0x50 && data[2] === 0x4e && data[3] === 0x47) {
|
|
526
|
+
return "image/png";
|
|
527
|
+
}
|
|
528
|
+
if (data.byteLength >= 3 && data[0] === 0xff && data[1] === 0xd8 && data[2] === 0xff) return "image/jpeg";
|
|
529
|
+
if (
|
|
530
|
+
data.byteLength >= 12 &&
|
|
531
|
+
data[0] === 0x52 &&
|
|
532
|
+
data[1] === 0x49 &&
|
|
533
|
+
data[2] === 0x46 &&
|
|
534
|
+
data[3] === 0x46 &&
|
|
535
|
+
data[8] === 0x57 &&
|
|
536
|
+
data[9] === 0x45 &&
|
|
537
|
+
data[10] === 0x42 &&
|
|
538
|
+
data[11] === 0x50
|
|
539
|
+
) {
|
|
540
|
+
return "image/webp";
|
|
541
|
+
}
|
|
542
|
+
if (
|
|
543
|
+
data.byteLength >= 6 &&
|
|
544
|
+
data[0] === 0x47 &&
|
|
545
|
+
data[1] === 0x49 &&
|
|
546
|
+
data[2] === 0x46 &&
|
|
547
|
+
data[3] === 0x38 &&
|
|
548
|
+
(data[4] === 0x37 || data[4] === 0x39) &&
|
|
549
|
+
data[5] === 0x61
|
|
550
|
+
) {
|
|
551
|
+
return "image/gif";
|
|
552
|
+
}
|
|
553
|
+
return undefined;
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
export function detectVideoMediaType(data: Uint8Array): MediaStoreVideoType | undefined {
|
|
557
|
+
if (data.byteLength >= 12 && data[4] === 0x66 && data[5] === 0x74 && data[6] === 0x79 && data[7] === 0x70) {
|
|
558
|
+
return "video/mp4";
|
|
559
|
+
}
|
|
560
|
+
if (data.byteLength >= 4 && data[0] === 0x1a && data[1] === 0x45 && data[2] === 0xdf && data[3] === 0xa3) {
|
|
561
|
+
return "video/webm";
|
|
562
|
+
}
|
|
563
|
+
return undefined;
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
function parseContentType(value: string | null | undefined): string | undefined {
|
|
567
|
+
if (value === null || value === undefined || value.trim() === "") return undefined;
|
|
568
|
+
return value.split(";", 1)[0]?.trim().toLowerCase();
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
export async function defaultImagineLookup(hostname: string): Promise<readonly string[]> {
|
|
572
|
+
const records = await dnsLookup(hostname, { all: true, verbatim: true });
|
|
573
|
+
return records.map((record) => record.address);
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
export async function assertSafeRemoteMediaUrl(raw: string, lookup: ImagineDnsLookup): Promise<URL> {
|
|
577
|
+
let url: URL;
|
|
578
|
+
try {
|
|
579
|
+
url = new URL(raw);
|
|
580
|
+
} catch {
|
|
581
|
+
throw new GrokImagineError("SSRF", "generated media URL is not a valid absolute URL");
|
|
582
|
+
}
|
|
583
|
+
if (url.protocol !== "https:") {
|
|
584
|
+
throw new GrokImagineError("SSRF", "generated media URL must be HTTPS");
|
|
585
|
+
}
|
|
586
|
+
if (url.username !== "" || url.password !== "") {
|
|
587
|
+
throw new GrokImagineError("SSRF", "generated media URL must not include credentials");
|
|
588
|
+
}
|
|
589
|
+
if (url.port !== "" && url.port !== "443") {
|
|
590
|
+
throw new GrokImagineError("SSRF", "generated media URL must use port 443");
|
|
591
|
+
}
|
|
592
|
+
const host = normalizeHostname(url.hostname);
|
|
593
|
+
if (host === "" || isBlockedHostname(host) || isIP(host) !== 0) {
|
|
594
|
+
throw new GrokImagineError("SSRF", "generated media host is not allowed");
|
|
595
|
+
}
|
|
596
|
+
if (!isAllowlistedImagineHost(host)) {
|
|
597
|
+
throw new GrokImagineError("SSRF", "generated media host is not an allowlisted xAI output host");
|
|
598
|
+
}
|
|
599
|
+
const addresses = [...(await lookup(host))];
|
|
600
|
+
if (addresses.length === 0) {
|
|
601
|
+
throw new GrokImagineError("SSRF", "generated media host did not resolve");
|
|
602
|
+
}
|
|
603
|
+
for (const address of addresses) {
|
|
604
|
+
if (isBlockedIp(address)) {
|
|
605
|
+
throw new GrokImagineError("SSRF", "generated media host resolved to a blocked address");
|
|
606
|
+
}
|
|
607
|
+
}
|
|
608
|
+
return url;
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
async function resolvePublicAddresses(hostname: string, lookup: ImagineDnsLookup): Promise<readonly string[]> {
|
|
612
|
+
const host = normalizeHostname(hostname);
|
|
613
|
+
if (host === "" || isBlockedHostname(host) || isIP(host) !== 0) {
|
|
614
|
+
throw new GrokImagineError("SSRF", "host is not allowed");
|
|
615
|
+
}
|
|
616
|
+
const addresses = [...(await lookup(host))];
|
|
617
|
+
if (addresses.length === 0) throw new GrokImagineError("SSRF", "host did not resolve");
|
|
618
|
+
for (const address of addresses) {
|
|
619
|
+
if (isBlockedIp(address)) throw new GrokImagineError("SSRF", "host resolved to a blocked address");
|
|
620
|
+
}
|
|
621
|
+
return addresses;
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
function createPinnedAgent(hostname: string, addresses: readonly string[]): Agent {
|
|
625
|
+
const expected = normalizeHostname(hostname);
|
|
626
|
+
const records = addresses.map((address) => {
|
|
627
|
+
const family = isIP(address);
|
|
628
|
+
if (family !== 4 && family !== 6) {
|
|
629
|
+
throw new GrokImagineError("SSRF", "pinned address is not a valid IP");
|
|
630
|
+
}
|
|
631
|
+
return { address, family };
|
|
632
|
+
});
|
|
633
|
+
const lookup = (
|
|
634
|
+
host: string,
|
|
635
|
+
options: { all?: boolean },
|
|
636
|
+
callback: (error: Error | null, address?: unknown, family?: number) => void,
|
|
637
|
+
): void => {
|
|
638
|
+
if (normalizeHostname(host) !== expected) {
|
|
639
|
+
callback(new GrokImagineError("SSRF", "refusing DNS lookup for an unpinned host"));
|
|
640
|
+
return;
|
|
641
|
+
}
|
|
642
|
+
if (options.all === true) {
|
|
643
|
+
callback(
|
|
644
|
+
null,
|
|
645
|
+
records.map((record) => ({ address: record.address, family: record.family })),
|
|
646
|
+
);
|
|
647
|
+
return;
|
|
648
|
+
}
|
|
649
|
+
const first = records[0];
|
|
650
|
+
if (first === undefined) {
|
|
651
|
+
callback(new GrokImagineError("SSRF", "no pinned addresses"));
|
|
652
|
+
return;
|
|
653
|
+
}
|
|
654
|
+
callback(null, first.address, first.family);
|
|
655
|
+
};
|
|
656
|
+
return new Agent({
|
|
657
|
+
connect: {
|
|
658
|
+
autoSelectFamily: false,
|
|
659
|
+
// undici's lookup typing is the single-address Node overload; we also
|
|
660
|
+
// handle { all: true } at runtime so Happy Eyeballs cannot re-query DNS.
|
|
661
|
+
lookup: lookup as never,
|
|
662
|
+
},
|
|
663
|
+
});
|
|
664
|
+
}
|
|
665
|
+
|
|
666
|
+
interface LimitedBodyReader {
|
|
667
|
+
read(): Promise<{ done: boolean; value?: Uint8Array }>;
|
|
668
|
+
cancel(): Promise<unknown>;
|
|
669
|
+
}
|
|
670
|
+
|
|
671
|
+
interface LimitedBodyResponse {
|
|
672
|
+
readonly status: number;
|
|
673
|
+
readonly headers: { get(name: string): string | null };
|
|
674
|
+
readonly body: unknown | null;
|
|
675
|
+
arrayBuffer(): Promise<ArrayBuffer>;
|
|
676
|
+
}
|
|
677
|
+
|
|
678
|
+
async function readLimitedBody(
|
|
679
|
+
response: LimitedBodyResponse,
|
|
680
|
+
maxBytes: number,
|
|
681
|
+
signal?: AbortSignal,
|
|
682
|
+
): Promise<Uint8Array> {
|
|
683
|
+
const throwIfAborted = (): void => {
|
|
684
|
+
if (signal?.aborted === true) {
|
|
685
|
+
throw new GrokImagineError("TIMEOUT", "Imagine response body read was aborted", { cause: signal.reason });
|
|
686
|
+
}
|
|
687
|
+
};
|
|
688
|
+
throwIfAborted();
|
|
689
|
+
const declared = Number(response.headers.get("content-length"));
|
|
690
|
+
if (Number.isFinite(declared) && declared > maxBytes) {
|
|
691
|
+
throw new GrokImagineError("MEDIA", `remote body exceeds the ${maxBytes} byte ceiling`);
|
|
692
|
+
}
|
|
693
|
+
if (response.body === null) {
|
|
694
|
+
const buffer = new Uint8Array(await response.arrayBuffer());
|
|
695
|
+
throwIfAborted();
|
|
696
|
+
if (buffer.byteLength > maxBytes) {
|
|
697
|
+
throw new GrokImagineError("MEDIA", `remote body exceeds the ${maxBytes} byte ceiling`);
|
|
698
|
+
}
|
|
699
|
+
return buffer;
|
|
700
|
+
}
|
|
701
|
+
if (
|
|
702
|
+
typeof response.body !== "object" ||
|
|
703
|
+
response.body === null ||
|
|
704
|
+
!("getReader" in response.body) ||
|
|
705
|
+
typeof response.body.getReader !== "function"
|
|
706
|
+
) {
|
|
707
|
+
throw new GrokImagineError("MEDIA", "remote body is not a readable byte stream");
|
|
708
|
+
}
|
|
709
|
+
const reader = response.body.getReader() as LimitedBodyReader;
|
|
710
|
+
const chunks: Uint8Array[] = [];
|
|
711
|
+
let size = 0;
|
|
712
|
+
const onAbort = (): void => {
|
|
713
|
+
void reader.cancel().catch(() => undefined);
|
|
714
|
+
};
|
|
715
|
+
if (signal?.aborted === true) onAbort();
|
|
716
|
+
else signal?.addEventListener("abort", onAbort, { once: true });
|
|
717
|
+
try {
|
|
718
|
+
while (true) {
|
|
719
|
+
throwIfAborted();
|
|
720
|
+
const { done, value } = await reader.read();
|
|
721
|
+
throwIfAborted();
|
|
722
|
+
if (done) break;
|
|
723
|
+
if (value === undefined) continue;
|
|
724
|
+
size += value.byteLength;
|
|
725
|
+
if (size > maxBytes) {
|
|
726
|
+
await reader.cancel().catch(() => undefined);
|
|
727
|
+
throw new GrokImagineError("MEDIA", `remote body exceeds the ${maxBytes} byte ceiling`);
|
|
728
|
+
}
|
|
729
|
+
chunks.push(value);
|
|
730
|
+
}
|
|
731
|
+
} finally {
|
|
732
|
+
signal?.removeEventListener("abort", onAbort);
|
|
733
|
+
}
|
|
734
|
+
const out = new Uint8Array(size);
|
|
735
|
+
let offset = 0;
|
|
736
|
+
for (const chunk of chunks) {
|
|
737
|
+
out.set(chunk, offset);
|
|
738
|
+
offset += chunk.byteLength;
|
|
739
|
+
}
|
|
740
|
+
return out;
|
|
741
|
+
}
|
|
742
|
+
|
|
743
|
+
function requestSignal(signal: AbortSignal | undefined, timeoutMs: number): AbortSignal {
|
|
744
|
+
const timeout = AbortSignal.timeout(timeoutMs);
|
|
745
|
+
return signal === undefined ? timeout : AbortSignal.any([signal, timeout]);
|
|
746
|
+
}
|
|
747
|
+
|
|
748
|
+
function mapUpstreamStatus(status: number, bodyText: string): GrokImagineError {
|
|
749
|
+
const detail = redactImagineMessage(bodyText.trim().slice(0, 300));
|
|
750
|
+
if (status === 401) {
|
|
751
|
+
return new GrokImagineError("AUTH", `xAI rejected the Imagine API key${detail === "" ? "" : `: ${detail}`}`, {
|
|
752
|
+
status,
|
|
753
|
+
});
|
|
754
|
+
}
|
|
755
|
+
if (status === 403) {
|
|
756
|
+
const message = `current xAI credentials cannot use Imagine${detail === "" ? "" : `: ${detail}`}`;
|
|
757
|
+
return new GrokImagineError("QUOTA", message, { status });
|
|
758
|
+
}
|
|
759
|
+
if (status === 429) {
|
|
760
|
+
const message = `xAI Imagine rate-limited the request${detail === "" ? "" : `: ${detail}`}`;
|
|
761
|
+
return new GrokImagineError("RATE_LIMIT", message, { status });
|
|
762
|
+
}
|
|
763
|
+
const message = `xAI Imagine request failed (${status})${detail === "" ? "" : `: ${detail}`}`;
|
|
764
|
+
return new GrokImagineError("UPSTREAM", message, { status });
|
|
765
|
+
}
|
|
766
|
+
|
|
767
|
+
export function createPinnedMediaTransport(lookup: ImagineDnsLookup = defaultImagineLookup): ImagineMediaTransport {
|
|
768
|
+
return {
|
|
769
|
+
async get(url, request) {
|
|
770
|
+
const validated = await assertSafeRemoteMediaUrl(url.href, lookup);
|
|
771
|
+
const addresses = await resolvePublicAddresses(validated.hostname, lookup);
|
|
772
|
+
const agent = createPinnedAgent(validated.hostname, addresses);
|
|
773
|
+
const signal = requestSignal(request.signal, request.timeoutMs);
|
|
774
|
+
try {
|
|
775
|
+
const response = await undiciFetch(validated, {
|
|
776
|
+
method: "GET",
|
|
777
|
+
redirect: "manual",
|
|
778
|
+
dispatcher: agent,
|
|
779
|
+
signal,
|
|
780
|
+
headers: { accept: request.accept },
|
|
781
|
+
});
|
|
782
|
+
if (response.status >= 300 && response.status < 400) {
|
|
783
|
+
const location = response.headers.get("location");
|
|
784
|
+
return location === null || location.trim() === ""
|
|
785
|
+
? { status: response.status }
|
|
786
|
+
: { status: response.status, location };
|
|
787
|
+
}
|
|
788
|
+
if (!response.ok) {
|
|
789
|
+
const text = new TextDecoder().decode(await readLimitedBody(response, 4096, signal));
|
|
790
|
+
throw mapUpstreamStatus(response.status, text);
|
|
791
|
+
}
|
|
792
|
+
const data = await readLimitedBody(response, request.maxBytes, signal);
|
|
793
|
+
const contentType = parseContentType(response.headers.get("content-type"));
|
|
794
|
+
if (contentType === undefined) return { status: response.status, data };
|
|
795
|
+
return { status: response.status, contentType, data };
|
|
796
|
+
} catch (error) {
|
|
797
|
+
if (error instanceof GrokImagineError) throw error;
|
|
798
|
+
const name = error instanceof Error ? error.name : "";
|
|
799
|
+
if (name === "TimeoutError" || name === "AbortError") {
|
|
800
|
+
throw new GrokImagineError("TIMEOUT", "Imagine media download timed out", { cause: error });
|
|
801
|
+
}
|
|
802
|
+
throw new GrokImagineError("MEDIA", `Imagine media download failed (${redactImagineMessage(error)})`, {
|
|
803
|
+
cause: error,
|
|
804
|
+
});
|
|
805
|
+
} finally {
|
|
806
|
+
await agent.close().catch(() => undefined);
|
|
807
|
+
}
|
|
808
|
+
},
|
|
809
|
+
};
|
|
810
|
+
}
|
|
811
|
+
|
|
812
|
+
export function createPinnedApiFetch(
|
|
813
|
+
lookup: ImagineDnsLookup = defaultImagineLookup,
|
|
814
|
+
maxBytes = DEFAULT_API_JSON_MAX_BYTES,
|
|
815
|
+
): ImagineFetch {
|
|
816
|
+
return async (input, init) => {
|
|
817
|
+
const url = input instanceof URL ? input : new URL(typeof input === "string" ? input : String(input));
|
|
818
|
+
if (url.origin !== XAI_API_ORIGIN || url.protocol !== "https:") {
|
|
819
|
+
throw new GrokImagineError("SSRF", "Imagine API URL escaped https://api.x.ai");
|
|
820
|
+
}
|
|
821
|
+
if (init?.body !== undefined && init.body !== null && typeof init.body !== "string") {
|
|
822
|
+
throw new GrokImagineError("INVALID_INPUT", "Imagine API transport accepts only a JSON string body");
|
|
823
|
+
}
|
|
824
|
+
const addresses = await resolvePublicAddresses(url.hostname, lookup);
|
|
825
|
+
const agent = createPinnedAgent(url.hostname, addresses);
|
|
826
|
+
const signal = init?.signal ?? undefined;
|
|
827
|
+
try {
|
|
828
|
+
const requestHeaders = init?.headers === undefined ? undefined : Object.fromEntries(new Headers(init.headers));
|
|
829
|
+
const response = await undiciFetch(url, {
|
|
830
|
+
...(init?.method === undefined ? {} : { method: init.method }),
|
|
831
|
+
...(init?.body === undefined || init.body === null ? {} : { body: init.body }),
|
|
832
|
+
...(requestHeaders === undefined ? {} : { headers: requestHeaders }),
|
|
833
|
+
...(signal === undefined ? {} : { signal }),
|
|
834
|
+
dispatcher: agent,
|
|
835
|
+
redirect: "error",
|
|
836
|
+
});
|
|
837
|
+
const data = await readLimitedBody(response, maxBytes, signal);
|
|
838
|
+
const responseHeaders: Record<string, string> = {};
|
|
839
|
+
response.headers.forEach((value, key) => {
|
|
840
|
+
responseHeaders[key] = value;
|
|
841
|
+
});
|
|
842
|
+
return new Response(Uint8Array.from(data).buffer, {
|
|
843
|
+
status: response.status,
|
|
844
|
+
statusText: response.statusText,
|
|
845
|
+
headers: responseHeaders,
|
|
846
|
+
});
|
|
847
|
+
} catch (error) {
|
|
848
|
+
if (error instanceof GrokImagineError) throw error;
|
|
849
|
+
const name = error instanceof Error ? error.name : "";
|
|
850
|
+
if (name === "TimeoutError" || name === "AbortError") {
|
|
851
|
+
throw new GrokImagineError("TIMEOUT", "Imagine API request timed out", { cause: error });
|
|
852
|
+
}
|
|
853
|
+
throw new GrokImagineError("UPSTREAM", `Imagine API request failed (${redactImagineMessage(error)})`, {
|
|
854
|
+
cause: error,
|
|
855
|
+
});
|
|
856
|
+
} finally {
|
|
857
|
+
await agent.close().catch(() => undefined);
|
|
858
|
+
}
|
|
859
|
+
};
|
|
860
|
+
}
|
|
861
|
+
|
|
862
|
+
export async function downloadRemoteImagineMedia(
|
|
863
|
+
initialUrl: string,
|
|
864
|
+
transport: ImagineMediaTransport,
|
|
865
|
+
options: {
|
|
866
|
+
lookup: ImagineDnsLookup;
|
|
867
|
+
timeoutMs: number;
|
|
868
|
+
maxBytes: number;
|
|
869
|
+
accept: string;
|
|
870
|
+
maxRedirects: number;
|
|
871
|
+
signal?: AbortSignal;
|
|
872
|
+
},
|
|
873
|
+
): Promise<ImagineDownloadResult> {
|
|
874
|
+
let current = initialUrl;
|
|
875
|
+
for (let hop = 0; hop <= options.maxRedirects; hop += 1) {
|
|
876
|
+
const url = await assertSafeRemoteMediaUrl(current, options.lookup);
|
|
877
|
+
const result = await transport.get(url, {
|
|
878
|
+
timeoutMs: options.timeoutMs,
|
|
879
|
+
maxBytes: options.maxBytes,
|
|
880
|
+
accept: options.accept,
|
|
881
|
+
...(options.signal === undefined ? {} : { signal: options.signal }),
|
|
882
|
+
});
|
|
883
|
+
if (result.status >= 300 && result.status < 400) {
|
|
884
|
+
if (result.location === undefined || result.location.trim() === "") {
|
|
885
|
+
throw new GrokImagineError("SSRF", "Imagine media redirect was missing a Location header");
|
|
886
|
+
}
|
|
887
|
+
try {
|
|
888
|
+
current = new URL(result.location, url).href;
|
|
889
|
+
} catch {
|
|
890
|
+
throw new GrokImagineError("SSRF", "Imagine media redirect Location is not a valid URL");
|
|
891
|
+
}
|
|
892
|
+
continue;
|
|
893
|
+
}
|
|
894
|
+
if (result.data === undefined) {
|
|
895
|
+
throw new GrokImagineError("MEDIA", "Imagine media download returned no body");
|
|
896
|
+
}
|
|
897
|
+
if (result.contentType === undefined) return { data: result.data };
|
|
898
|
+
return { data: result.data, contentType: result.contentType };
|
|
899
|
+
}
|
|
900
|
+
throw new GrokImagineError("SSRF", "Imagine media download exceeded the redirect limit");
|
|
901
|
+
}
|
|
902
|
+
|
|
903
|
+
export function createPinnedImagineDownloader(
|
|
904
|
+
options: { lookup?: ImagineDnsLookup; maxRedirects?: number } = {},
|
|
905
|
+
): ImagineDownloader {
|
|
906
|
+
const lookup = options.lookup ?? defaultImagineLookup;
|
|
907
|
+
const transport = createPinnedMediaTransport(lookup);
|
|
908
|
+
const maxRedirects = options.maxRedirects ?? DEFAULT_MAX_REDIRECTS;
|
|
909
|
+
return {
|
|
910
|
+
download: (request) =>
|
|
911
|
+
downloadRemoteImagineMedia(request.url, transport, {
|
|
912
|
+
lookup,
|
|
913
|
+
timeoutMs: request.timeoutMs,
|
|
914
|
+
maxBytes: request.maxBytes,
|
|
915
|
+
accept: request.accept,
|
|
916
|
+
maxRedirects,
|
|
917
|
+
...(request.signal === undefined ? {} : { signal: request.signal }),
|
|
918
|
+
}),
|
|
919
|
+
};
|
|
920
|
+
}
|
|
921
|
+
|
|
922
|
+
/**
|
|
923
|
+
* Test-only adapter around an injected fetch. Ordinary mock fetch is not
|
|
924
|
+
* production-safe: it cannot pin DNS at connect time.
|
|
925
|
+
*/
|
|
926
|
+
export function createImagineDownloaderFromFetch(
|
|
927
|
+
fetchImpl: ImagineFetch,
|
|
928
|
+
options: { trustedTestTransport: true; lookup?: ImagineDnsLookup; maxRedirects?: number },
|
|
929
|
+
): ImagineDownloader {
|
|
930
|
+
if (options.trustedTestTransport !== true) {
|
|
931
|
+
throw new GrokImagineError("SSRF", "fetch-based media transport is not production-safe");
|
|
932
|
+
}
|
|
933
|
+
const lookup = options.lookup ?? defaultImagineLookup;
|
|
934
|
+
const maxRedirects = options.maxRedirects ?? DEFAULT_MAX_REDIRECTS;
|
|
935
|
+
const transport: ImagineMediaTransport = {
|
|
936
|
+
async get(url, request) {
|
|
937
|
+
const signal = requestSignal(request.signal, request.timeoutMs);
|
|
938
|
+
try {
|
|
939
|
+
const response = await fetchImpl(url, {
|
|
940
|
+
method: "GET",
|
|
941
|
+
redirect: "manual",
|
|
942
|
+
signal,
|
|
943
|
+
headers: { accept: request.accept },
|
|
944
|
+
});
|
|
945
|
+
if (response.status >= 300 && response.status < 400) {
|
|
946
|
+
const location = response.headers.get("location");
|
|
947
|
+
return location === null || location.trim() === ""
|
|
948
|
+
? { status: response.status }
|
|
949
|
+
: { status: response.status, location };
|
|
950
|
+
}
|
|
951
|
+
if (!response.ok) {
|
|
952
|
+
const text = new TextDecoder().decode(await readLimitedBody(response, 4096, signal));
|
|
953
|
+
throw mapUpstreamStatus(response.status, text);
|
|
954
|
+
}
|
|
955
|
+
const data = await readLimitedBody(response, request.maxBytes, signal);
|
|
956
|
+
const contentType = parseContentType(response.headers.get("content-type"));
|
|
957
|
+
if (contentType === undefined) return { status: response.status, data };
|
|
958
|
+
return { status: response.status, contentType, data };
|
|
959
|
+
} catch (error) {
|
|
960
|
+
if (error instanceof GrokImagineError) throw error;
|
|
961
|
+
const name = error instanceof Error ? error.name : "";
|
|
962
|
+
if (name === "TimeoutError" || name === "AbortError") {
|
|
963
|
+
throw new GrokImagineError("TIMEOUT", "Imagine media download timed out", { cause: error });
|
|
964
|
+
}
|
|
965
|
+
throw new GrokImagineError("MEDIA", `Imagine media download failed (${redactImagineMessage(error)})`, {
|
|
966
|
+
cause: error,
|
|
967
|
+
});
|
|
968
|
+
}
|
|
969
|
+
},
|
|
970
|
+
};
|
|
971
|
+
return {
|
|
972
|
+
download: (request) =>
|
|
973
|
+
downloadRemoteImagineMedia(request.url, transport, {
|
|
974
|
+
lookup,
|
|
975
|
+
timeoutMs: request.timeoutMs,
|
|
976
|
+
maxBytes: request.maxBytes,
|
|
977
|
+
accept: request.accept,
|
|
978
|
+
maxRedirects,
|
|
979
|
+
...(request.signal === undefined ? {} : { signal: request.signal }),
|
|
980
|
+
}),
|
|
981
|
+
};
|
|
982
|
+
}
|
|
983
|
+
|
|
984
|
+
function requirePrompt(prompt: string | undefined): string {
|
|
985
|
+
if (typeof prompt !== "string" || prompt.trim() === "") {
|
|
986
|
+
throw new GrokImagineError("INVALID_INPUT", "prompt must be a non-empty string");
|
|
987
|
+
}
|
|
988
|
+
if (prompt.length > IMAGINE_PROMPT_MAX_LENGTH) {
|
|
989
|
+
throw new GrokImagineError(
|
|
990
|
+
"INVALID_INPUT",
|
|
991
|
+
`prompt must be ${String(IMAGINE_PROMPT_MAX_LENGTH)} characters or fewer (got ${String(prompt.length)})`,
|
|
992
|
+
);
|
|
993
|
+
}
|
|
994
|
+
return prompt;
|
|
995
|
+
}
|
|
996
|
+
|
|
997
|
+
/**
|
|
998
|
+
* Validate a tool-supplied Imagine video `requestId`. Returns the id unchanged
|
|
999
|
+
* on success or throws an `INVALID_INPUT` error. Exposed so capability tools
|
|
1000
|
+
* fail closed at the boundary instead of dispatching a malformed id to the
|
|
1001
|
+
* internal client.
|
|
1002
|
+
*/
|
|
1003
|
+
export function parseVideoRequestId(value: unknown): string {
|
|
1004
|
+
if (typeof value !== "string") {
|
|
1005
|
+
throw new GrokImagineError("INVALID_INPUT", "video request id must be a string");
|
|
1006
|
+
}
|
|
1007
|
+
if (!VIDEO_REQUEST_ID_PATTERN.test(value)) {
|
|
1008
|
+
throw new GrokImagineError(
|
|
1009
|
+
"INVALID_INPUT",
|
|
1010
|
+
"video request id must match ^[A-Za-z0-9_-]{1,256}$ and be a safe ASCII identifier",
|
|
1011
|
+
);
|
|
1012
|
+
}
|
|
1013
|
+
return value;
|
|
1014
|
+
}
|
|
1015
|
+
|
|
1016
|
+
/** Enforce min/max bounds on a candidate image-id array. */
|
|
1017
|
+
export function clampImagineImageIds(ids: readonly unknown[]): readonly string[] {
|
|
1018
|
+
if (!Array.isArray(ids)) {
|
|
1019
|
+
throw new GrokImagineError("INVALID_INPUT", "imageIds must be an array");
|
|
1020
|
+
}
|
|
1021
|
+
if (ids.length < IMAGINE_IMAGE_IDS_MIN || ids.length > IMAGINE_IMAGE_IDS_MAX) {
|
|
1022
|
+
throw new GrokImagineError(
|
|
1023
|
+
"INVALID_INPUT",
|
|
1024
|
+
`imageIds must contain between ${String(IMAGINE_IMAGE_IDS_MIN)} and ${String(IMAGINE_IMAGE_IDS_MAX)} ids (got ${String(ids.length)})`,
|
|
1025
|
+
);
|
|
1026
|
+
}
|
|
1027
|
+
return ids.map((entry, index) => {
|
|
1028
|
+
if (typeof entry !== "string" || entry.length === 0 || entry.length > 128) {
|
|
1029
|
+
throw new GrokImagineError("INVALID_INPUT", `imageIds[${String(index)}] must be a non-empty ASCII id`);
|
|
1030
|
+
}
|
|
1031
|
+
return entry;
|
|
1032
|
+
});
|
|
1033
|
+
}
|
|
1034
|
+
|
|
1035
|
+
class ImagineAbortError extends Error {
|
|
1036
|
+
readonly name = "ImagineAbortError";
|
|
1037
|
+
constructor(reason?: unknown) {
|
|
1038
|
+
super("Imagine operation aborted");
|
|
1039
|
+
this.cause = reason;
|
|
1040
|
+
}
|
|
1041
|
+
}
|
|
1042
|
+
|
|
1043
|
+
function isImagineAbortError(error: unknown): error is ImagineAbortError {
|
|
1044
|
+
return error instanceof ImagineAbortError;
|
|
1045
|
+
}
|
|
1046
|
+
|
|
1047
|
+
/**
|
|
1048
|
+
* Combine a caller-supplied external `AbortSignal` with the client's internal
|
|
1049
|
+
* `AbortController` so a `dispose()` cancels in-flight API, download, and
|
|
1050
|
+
* persistence work without leaving the operation running in the background.
|
|
1051
|
+
*/
|
|
1052
|
+
function composeSignals(
|
|
1053
|
+
external: AbortSignal | undefined,
|
|
1054
|
+
internal: AbortSignal,
|
|
1055
|
+
): { signal: AbortSignal; observe: () => void } {
|
|
1056
|
+
return {
|
|
1057
|
+
signal: external === undefined ? internal : AbortSignal.any([external, internal]),
|
|
1058
|
+
// AbortSignal.any owns and releases its internal listeners; callers retain
|
|
1059
|
+
// a uniform cleanup shape for runtimes that optimize this helper later.
|
|
1060
|
+
observe: () => undefined,
|
|
1061
|
+
};
|
|
1062
|
+
}
|
|
1063
|
+
|
|
1064
|
+
function assertImagineSignalActive(signal: AbortSignal, operation: ImagineOperation): void {
|
|
1065
|
+
if (signal.aborted) {
|
|
1066
|
+
throw new GrokImagineError("TIMEOUT", `Imagine ${operation} was aborted before work started`, {
|
|
1067
|
+
cause: signal.reason,
|
|
1068
|
+
});
|
|
1069
|
+
}
|
|
1070
|
+
}
|
|
1071
|
+
|
|
1072
|
+
function requireApiKey(value: string | undefined, operation: ImagineOperation): string {
|
|
1073
|
+
if (typeof value !== "string" || value.trim() === "") {
|
|
1074
|
+
throw new GrokImagineError(
|
|
1075
|
+
"MISSING_CREDENTIAL",
|
|
1076
|
+
`xAI API key is missing for ${operation}. Grok Imagine does not fall back to OAuth.`,
|
|
1077
|
+
);
|
|
1078
|
+
}
|
|
1079
|
+
return value;
|
|
1080
|
+
}
|
|
1081
|
+
|
|
1082
|
+
function requireImageModel(model: string | undefined): string {
|
|
1083
|
+
const value = model ?? GROK_IMAGINE_IMAGE_MODEL;
|
|
1084
|
+
if (value !== GROK_IMAGINE_IMAGE_MODEL) {
|
|
1085
|
+
throw new GrokImagineError("INVALID_INPUT", "image model is not an accepted Imagine model");
|
|
1086
|
+
}
|
|
1087
|
+
return value;
|
|
1088
|
+
}
|
|
1089
|
+
|
|
1090
|
+
function requireVideoModel(model: string | undefined): string {
|
|
1091
|
+
const value = model ?? GROK_IMAGINE_VIDEO_MODEL;
|
|
1092
|
+
if (value !== GROK_IMAGINE_VIDEO_MODEL) {
|
|
1093
|
+
throw new GrokImagineError("INVALID_INPUT", "video model is not an accepted Imagine model");
|
|
1094
|
+
}
|
|
1095
|
+
return value;
|
|
1096
|
+
}
|
|
1097
|
+
|
|
1098
|
+
function optionalEnum(value: string | undefined, allowed: ReadonlySet<string>, label: string): string | undefined {
|
|
1099
|
+
if (value === undefined) return undefined;
|
|
1100
|
+
if (!allowed.has(value)) {
|
|
1101
|
+
throw new GrokImagineError("INVALID_INPUT", `${label} is not an accepted Imagine value`);
|
|
1102
|
+
}
|
|
1103
|
+
return value;
|
|
1104
|
+
}
|
|
1105
|
+
|
|
1106
|
+
function optionalDuration(value: number | undefined): number | undefined {
|
|
1107
|
+
if (value === undefined) return undefined;
|
|
1108
|
+
const min = IMAGINE_VIDEO_MIN_DURATION_SECONDS;
|
|
1109
|
+
const max = IMAGINE_VIDEO_MAX_DURATION_SECONDS;
|
|
1110
|
+
if (!Number.isSafeInteger(value) || value < min || value > max) {
|
|
1111
|
+
throw new GrokImagineError("INVALID_INPUT", `duration must be an integer between ${min} and ${max}`);
|
|
1112
|
+
}
|
|
1113
|
+
return value;
|
|
1114
|
+
}
|
|
1115
|
+
|
|
1116
|
+
function classifyOfficialVideoStatus(raw: unknown): ImagineVideoJobStatus {
|
|
1117
|
+
if (raw === "pending") return "pending";
|
|
1118
|
+
if (raw === "done") return "completed";
|
|
1119
|
+
if (raw === "failed") return "failed";
|
|
1120
|
+
throw new GrokImagineError("UPSTREAM", "Imagine video status is not an official value");
|
|
1121
|
+
}
|
|
1122
|
+
|
|
1123
|
+
function officialVideoUrl(payload: Record<string, unknown>): string | undefined {
|
|
1124
|
+
const video = isRecord(payload["video"]) ? payload["video"] : undefined;
|
|
1125
|
+
const url = video?.["url"];
|
|
1126
|
+
return typeof url === "string" && url.length > 0 ? url : undefined;
|
|
1127
|
+
}
|
|
1128
|
+
|
|
1129
|
+
function officialVideoError(payload: Record<string, unknown>): string | undefined {
|
|
1130
|
+
const error = isRecord(payload["error"]) ? payload["error"] : undefined;
|
|
1131
|
+
const message = error?.["message"];
|
|
1132
|
+
return typeof message === "string" && message.length > 0 ? redactImagineMessage(message) : undefined;
|
|
1133
|
+
}
|
|
1134
|
+
|
|
1135
|
+
/**
|
|
1136
|
+
* Strict base64 decoder. Node's decoder ignores some malformed characters and
|
|
1137
|
+
* padding, so validate the alphabet and compare a canonical re-encoding. Both
|
|
1138
|
+
* canonical padded input and canonical unpadded input are accepted.
|
|
1139
|
+
*/
|
|
1140
|
+
const BASE64_STRICT_RE = /^[A-Za-z0-9+/]+={0,2}$/u;
|
|
1141
|
+
function decodeBase64(value: string, maxBytes: number): Uint8Array {
|
|
1142
|
+
if (typeof value !== "string" || value.length === 0) {
|
|
1143
|
+
throw new GrokImagineError("UPSTREAM", "Imagine image b64_json must be a non-empty string");
|
|
1144
|
+
}
|
|
1145
|
+
if (!BASE64_STRICT_RE.test(value)) {
|
|
1146
|
+
throw new GrokImagineError("UPSTREAM", "Imagine image b64_json contains non-base64 characters");
|
|
1147
|
+
}
|
|
1148
|
+
const remainder = value.length % 4;
|
|
1149
|
+
if (remainder === 1 || (value.includes("=") && remainder !== 0)) {
|
|
1150
|
+
throw new GrokImagineError("UPSTREAM", "Imagine image b64_json has invalid padding");
|
|
1151
|
+
}
|
|
1152
|
+
const maxChars = Math.ceil(maxBytes / 3) * 4 + 4;
|
|
1153
|
+
if (value.length > maxChars) {
|
|
1154
|
+
throw new GrokImagineError("MEDIA", `Imagine image b64_json exceeds the ${maxBytes} byte ceiling`);
|
|
1155
|
+
}
|
|
1156
|
+
const decoded = Buffer.from(value, "base64");
|
|
1157
|
+
if (decoded.byteLength === 0) {
|
|
1158
|
+
throw new GrokImagineError("UPSTREAM", "Imagine image b64_json decoded to an empty buffer");
|
|
1159
|
+
}
|
|
1160
|
+
const canonical = decoded.toString("base64");
|
|
1161
|
+
const inputWithoutPadding = value.replace(/=+$/u, "");
|
|
1162
|
+
if (canonical.replace(/=+$/u, "") !== inputWithoutPadding || (value.includes("=") && canonical !== value)) {
|
|
1163
|
+
throw new GrokImagineError("UPSTREAM", "Imagine image b64_json is not canonical base64");
|
|
1164
|
+
}
|
|
1165
|
+
if (decoded.byteLength > maxBytes) {
|
|
1166
|
+
throw new GrokImagineError("MEDIA", `Imagine image exceeds the ${maxBytes} byte ceiling`);
|
|
1167
|
+
}
|
|
1168
|
+
return Uint8Array.from(decoded);
|
|
1169
|
+
}
|
|
1170
|
+
|
|
1171
|
+
function resolveImageMediaType(data: Uint8Array, declared: string | undefined): ImagineImageMediaType {
|
|
1172
|
+
const detected = detectImageMediaType(data);
|
|
1173
|
+
if (detected === undefined) {
|
|
1174
|
+
throw new GrokImagineError("MEDIA", "downloaded Imagine image is not a supported raster");
|
|
1175
|
+
}
|
|
1176
|
+
if (
|
|
1177
|
+
declared !== undefined &&
|
|
1178
|
+
declared !== "application/octet-stream" &&
|
|
1179
|
+
declared !== "binary/octet-stream" &&
|
|
1180
|
+
declared !== detected
|
|
1181
|
+
) {
|
|
1182
|
+
throw new GrokImagineError("MEDIA", `Imagine image Content-Type ${declared} does not match its bytes`);
|
|
1183
|
+
}
|
|
1184
|
+
return detected;
|
|
1185
|
+
}
|
|
1186
|
+
|
|
1187
|
+
function resolveVideoMediaType(data: Uint8Array, declared: string | undefined): MediaStoreVideoType {
|
|
1188
|
+
const detected = detectVideoMediaType(data);
|
|
1189
|
+
if (detected === undefined) {
|
|
1190
|
+
throw new GrokImagineError("MEDIA", "downloaded Imagine video is not MP4 or WebM");
|
|
1191
|
+
}
|
|
1192
|
+
if (
|
|
1193
|
+
declared !== undefined &&
|
|
1194
|
+
declared !== "application/octet-stream" &&
|
|
1195
|
+
declared !== "binary/octet-stream" &&
|
|
1196
|
+
declared !== detected
|
|
1197
|
+
) {
|
|
1198
|
+
throw new GrokImagineError("MEDIA", `Imagine video Content-Type ${declared} does not match its bytes`);
|
|
1199
|
+
}
|
|
1200
|
+
return detected;
|
|
1201
|
+
}
|
|
1202
|
+
|
|
1203
|
+
export class GrokImagineClient {
|
|
1204
|
+
private readonly resolveApiKey: ImagineApiKeyResolver;
|
|
1205
|
+
private readonly attachments: ImagineAttachmentStore;
|
|
1206
|
+
private readonly media: MediaStore;
|
|
1207
|
+
private readonly apiFetch: ImagineFetch;
|
|
1208
|
+
private readonly downloader: ImagineDownloader;
|
|
1209
|
+
private readonly imageDownloadTimeoutMs: number;
|
|
1210
|
+
private readonly videoDownloadTimeoutMs: number;
|
|
1211
|
+
private readonly apiTimeoutMs: number;
|
|
1212
|
+
private readonly maxRedirects: number;
|
|
1213
|
+
private readonly imageMaxBytes: number;
|
|
1214
|
+
private readonly apiJsonMaxBytes: number;
|
|
1215
|
+
private readonly videoResults = new Map<string, ImagineVideoStatusResult>();
|
|
1216
|
+
private readonly videoInflight = new Map<string, Promise<ImagineVideoStatusResult>>();
|
|
1217
|
+
private readonly disposeController = new AbortController();
|
|
1218
|
+
private disposed = false;
|
|
1219
|
+
|
|
1220
|
+
constructor(options: GrokImagineClientOptions) {
|
|
1221
|
+
this.resolveApiKey = options.resolveApiKey;
|
|
1222
|
+
this.attachments = options.attachments;
|
|
1223
|
+
this.media = options.media;
|
|
1224
|
+
this.imageDownloadTimeoutMs = options.imageDownloadTimeoutMs ?? DEFAULT_IMAGE_DOWNLOAD_TIMEOUT_MS;
|
|
1225
|
+
this.videoDownloadTimeoutMs = options.videoDownloadTimeoutMs ?? DEFAULT_VIDEO_DOWNLOAD_TIMEOUT_MS;
|
|
1226
|
+
this.apiTimeoutMs = options.apiTimeoutMs ?? DEFAULT_API_TIMEOUT_MS;
|
|
1227
|
+
this.maxRedirects = options.maxRedirects ?? DEFAULT_MAX_REDIRECTS;
|
|
1228
|
+
this.imageMaxBytes =
|
|
1229
|
+
options.imageMaxBytes ?? options.attachments.imageLimits?.maxImageBytes ?? DEFAULT_IMAGE_DOWNLOAD_MAX_BYTES;
|
|
1230
|
+
this.apiJsonMaxBytes = options.apiJsonMaxBytes ?? DEFAULT_API_JSON_MAX_BYTES;
|
|
1231
|
+
this.apiFetch = options.fetch ?? createPinnedApiFetch(defaultImagineLookup, this.apiJsonMaxBytes);
|
|
1232
|
+
this.downloader = options.downloader ?? createPinnedImagineDownloader({ maxRedirects: this.maxRedirects });
|
|
1233
|
+
}
|
|
1234
|
+
|
|
1235
|
+
/**
|
|
1236
|
+
* Permanently retire this client. In-flight API calls, downloads, and
|
|
1237
|
+
* media persistence operations are aborted; subsequent operations fail
|
|
1238
|
+
* closed with an `INVALID_INPUT` error until callers construct a new client.
|
|
1239
|
+
*/
|
|
1240
|
+
dispose(): void {
|
|
1241
|
+
if (this.disposed) return;
|
|
1242
|
+
this.disposed = true;
|
|
1243
|
+
this.disposeController.abort(new ImagineAbortError("client disposed"));
|
|
1244
|
+
for (const inflight of this.videoInflight.values()) {
|
|
1245
|
+
inflight.catch(() => undefined);
|
|
1246
|
+
}
|
|
1247
|
+
this.videoInflight.clear();
|
|
1248
|
+
}
|
|
1249
|
+
|
|
1250
|
+
/** @returns true once {@link dispose} has been called. */
|
|
1251
|
+
get isDisposed(): boolean {
|
|
1252
|
+
return this.disposed;
|
|
1253
|
+
}
|
|
1254
|
+
|
|
1255
|
+
private assertWritable(operation: ImagineOperation): void {
|
|
1256
|
+
if (this.disposed) {
|
|
1257
|
+
throw new GrokImagineError(
|
|
1258
|
+
"INVALID_INPUT",
|
|
1259
|
+
`Imagine client was disposed before ${operation}; refusing to start new work`,
|
|
1260
|
+
);
|
|
1261
|
+
}
|
|
1262
|
+
}
|
|
1263
|
+
|
|
1264
|
+
async generateImage(input: GenerateImagineImageInput, signal?: AbortSignal): Promise<ImagineImageResult> {
|
|
1265
|
+
this.assertWritable("image.generate");
|
|
1266
|
+
const { signal: external, observe } = composeSignals(signal, this.disposeController.signal);
|
|
1267
|
+
try {
|
|
1268
|
+
assertImagineSignalActive(external, "image.generate");
|
|
1269
|
+
const prompt = requirePrompt(input.prompt);
|
|
1270
|
+
const model = requireImageModel(input.model);
|
|
1271
|
+
const n = input.n ?? 1;
|
|
1272
|
+
if (!Number.isSafeInteger(n) || n < 1 || n > IMAGINE_IMAGE_MAX_N) {
|
|
1273
|
+
throw new GrokImagineError("INVALID_INPUT", `n must be an integer between 1 and ${IMAGINE_IMAGE_MAX_N}`);
|
|
1274
|
+
}
|
|
1275
|
+
const aspectRatio = optionalEnum(input.aspectRatio, IMAGE_ASPECT_SET, "aspectRatio");
|
|
1276
|
+
const resolution = optionalEnum(input.resolution, IMAGE_RESOLUTION_SET, "resolution");
|
|
1277
|
+
const apiKey = requireApiKey(await this.resolveCredential("image.generate"), "image.generate");
|
|
1278
|
+
const body: Record<string, unknown> = {
|
|
1279
|
+
model,
|
|
1280
|
+
prompt,
|
|
1281
|
+
n,
|
|
1282
|
+
response_format: "url",
|
|
1283
|
+
};
|
|
1284
|
+
if (aspectRatio !== undefined) body["aspect_ratio"] = aspectRatio;
|
|
1285
|
+
if (resolution !== undefined) body["resolution"] = resolution;
|
|
1286
|
+
const payload = await this.apiJson("POST", GROK_IMAGINE_IMAGE_PATH, apiKey, body, external);
|
|
1287
|
+
const rows = Array.isArray(payload["data"]) ? payload["data"] : undefined;
|
|
1288
|
+
if (rows === undefined) {
|
|
1289
|
+
throw new GrokImagineError("UPSTREAM", "Imagine image response did not include data");
|
|
1290
|
+
}
|
|
1291
|
+
const images: ImaginePersistedImage[] = [];
|
|
1292
|
+
for (const row of rows) {
|
|
1293
|
+
if (!isRecord(row)) {
|
|
1294
|
+
throw new GrokImagineError("UPSTREAM", "Imagine image data row is not an object");
|
|
1295
|
+
}
|
|
1296
|
+
images.push(await this.persistGeneratedImage(row, input.name, external));
|
|
1297
|
+
}
|
|
1298
|
+
const first = images[0];
|
|
1299
|
+
if (first === undefined) {
|
|
1300
|
+
throw new GrokImagineError("UPSTREAM", "Imagine image response did not include any images");
|
|
1301
|
+
}
|
|
1302
|
+
return {
|
|
1303
|
+
model,
|
|
1304
|
+
images,
|
|
1305
|
+
attachment: first.attachment,
|
|
1306
|
+
path: first.path,
|
|
1307
|
+
};
|
|
1308
|
+
} finally {
|
|
1309
|
+
observe();
|
|
1310
|
+
}
|
|
1311
|
+
}
|
|
1312
|
+
|
|
1313
|
+
async startVideo(input: StartImagineVideoInput, signal?: AbortSignal): Promise<ImagineVideoStartResult> {
|
|
1314
|
+
this.assertWritable("video.start");
|
|
1315
|
+
const { signal: external, observe } = composeSignals(signal, this.disposeController.signal);
|
|
1316
|
+
try {
|
|
1317
|
+
assertImagineSignalActive(external, "video.start");
|
|
1318
|
+
const prompt = requirePrompt(input.prompt);
|
|
1319
|
+
const model = requireVideoModel(input.model);
|
|
1320
|
+
const duration = optionalDuration(input.duration);
|
|
1321
|
+
const aspectRatio = optionalEnum(input.aspectRatio, VIDEO_ASPECT_SET, "aspectRatio");
|
|
1322
|
+
const resolution = optionalEnum(input.resolution, VIDEO_RESOLUTION_SET, "resolution");
|
|
1323
|
+
const apiKey = requireApiKey(await this.resolveCredential("video.start"), "video.start");
|
|
1324
|
+
const body: Record<string, unknown> = { model, prompt };
|
|
1325
|
+
if (duration !== undefined) body["duration"] = duration;
|
|
1326
|
+
if (aspectRatio !== undefined) body["aspect_ratio"] = aspectRatio;
|
|
1327
|
+
if (resolution !== undefined) body["resolution"] = resolution;
|
|
1328
|
+
const payload = await this.apiJson("POST", GROK_IMAGINE_VIDEO_START_PATH, apiKey, body, external);
|
|
1329
|
+
const requestId = payload["request_id"];
|
|
1330
|
+
if (typeof requestId !== "string" || !VIDEO_REQUEST_ID_PATTERN.test(requestId)) {
|
|
1331
|
+
throw new GrokImagineError("UPSTREAM", "Imagine video start did not return a safe request_id");
|
|
1332
|
+
}
|
|
1333
|
+
return { model, requestId, status: "pending" };
|
|
1334
|
+
} finally {
|
|
1335
|
+
observe();
|
|
1336
|
+
}
|
|
1337
|
+
}
|
|
1338
|
+
|
|
1339
|
+
async videoStatus(
|
|
1340
|
+
requestId: string,
|
|
1341
|
+
options: { name?: string; signal?: AbortSignal } = {},
|
|
1342
|
+
): Promise<ImagineVideoStatusResult> {
|
|
1343
|
+
const validated = parseVideoRequestId(requestId);
|
|
1344
|
+
this.assertWritable("video.status");
|
|
1345
|
+
const { signal: external, observe } = composeSignals(options.signal, this.disposeController.signal);
|
|
1346
|
+
try {
|
|
1347
|
+
assertImagineSignalActive(external, "video.status");
|
|
1348
|
+
} catch (error) {
|
|
1349
|
+
observe();
|
|
1350
|
+
throw error;
|
|
1351
|
+
}
|
|
1352
|
+
const cached = this.videoResults.get(validated);
|
|
1353
|
+
if (cached !== undefined && cached.status !== "pending") {
|
|
1354
|
+
observe();
|
|
1355
|
+
return cloneVideoStatus(cached);
|
|
1356
|
+
}
|
|
1357
|
+
const poll = (): Promise<ImagineVideoStatusResult> =>
|
|
1358
|
+
this.pollVideo(validated, { ...options, signal: external }).catch((error: unknown) => {
|
|
1359
|
+
if (isImagineAbortError(error)) {
|
|
1360
|
+
throw new GrokImagineError("TIMEOUT", `video polling for ${validated} was aborted`, { cause: error });
|
|
1361
|
+
}
|
|
1362
|
+
throw error;
|
|
1363
|
+
});
|
|
1364
|
+
if (options.signal !== undefined) return poll().finally(observe);
|
|
1365
|
+
const inflight = this.videoInflight.get(validated);
|
|
1366
|
+
if (inflight !== undefined) {
|
|
1367
|
+
observe();
|
|
1368
|
+
return inflight;
|
|
1369
|
+
}
|
|
1370
|
+
const run = poll().finally(() => {
|
|
1371
|
+
observe();
|
|
1372
|
+
if (this.videoInflight.get(validated) === run) {
|
|
1373
|
+
this.videoInflight.delete(validated);
|
|
1374
|
+
}
|
|
1375
|
+
});
|
|
1376
|
+
this.videoInflight.set(validated, run);
|
|
1377
|
+
return run;
|
|
1378
|
+
}
|
|
1379
|
+
|
|
1380
|
+
private async pollVideo(
|
|
1381
|
+
requestId: string,
|
|
1382
|
+
options: { name?: string; signal?: AbortSignal },
|
|
1383
|
+
): Promise<ImagineVideoStatusResult> {
|
|
1384
|
+
const apiKey = requireApiKey(await this.resolveCredential("video.status"), "video.status");
|
|
1385
|
+
const payload = await this.apiJson("GET", grokImagineVideoStatusPath(requestId), apiKey, undefined, options.signal);
|
|
1386
|
+
const status = classifyOfficialVideoStatus(payload["status"]);
|
|
1387
|
+
if (status === "pending") return { requestId, status };
|
|
1388
|
+
if (status === "failed") {
|
|
1389
|
+
const result: ImagineVideoStatusResult = { requestId, status };
|
|
1390
|
+
const error = officialVideoError(payload);
|
|
1391
|
+
if (error !== undefined) result.error = error;
|
|
1392
|
+
this.rememberVideo(requestId, result);
|
|
1393
|
+
return result;
|
|
1394
|
+
}
|
|
1395
|
+
const video = isRecord(payload["video"]) ? payload["video"] : undefined;
|
|
1396
|
+
if (video?.["respect_moderation"] === false) {
|
|
1397
|
+
const result: ImagineVideoStatusResult = {
|
|
1398
|
+
requestId,
|
|
1399
|
+
status: "failed",
|
|
1400
|
+
error: "video did not respect moderation rules",
|
|
1401
|
+
};
|
|
1402
|
+
this.rememberVideo(requestId, result);
|
|
1403
|
+
return result;
|
|
1404
|
+
}
|
|
1405
|
+
const url = officialVideoUrl(payload);
|
|
1406
|
+
if (url === undefined) {
|
|
1407
|
+
throw new GrokImagineError("UPSTREAM", "completed Imagine video did not include a downloadable URL");
|
|
1408
|
+
}
|
|
1409
|
+
if (this.disposed) {
|
|
1410
|
+
throw new GrokImagineError("INVALID_INPUT", "Imagine client was disposed before video artifact save");
|
|
1411
|
+
}
|
|
1412
|
+
const downloaded = await this.downloader.download({
|
|
1413
|
+
url,
|
|
1414
|
+
timeoutMs: this.videoDownloadTimeoutMs,
|
|
1415
|
+
maxBytes: this.media.maxBytes,
|
|
1416
|
+
accept: "video/mp4,video/webm",
|
|
1417
|
+
...(options.signal === undefined ? {} : { signal: options.signal }),
|
|
1418
|
+
});
|
|
1419
|
+
if (this.disposed) {
|
|
1420
|
+
throw new GrokImagineError("INVALID_INPUT", "Imagine client was disposed after video download");
|
|
1421
|
+
}
|
|
1422
|
+
const mediaType = resolveVideoMediaType(downloaded.data, downloaded.contentType);
|
|
1423
|
+
const operationAborted = (): boolean => options.signal?.aborted === true || this.disposed;
|
|
1424
|
+
if (operationAborted()) {
|
|
1425
|
+
throw new GrokImagineError("TIMEOUT", "Imagine video persistence was aborted", {
|
|
1426
|
+
cause: options.signal?.reason,
|
|
1427
|
+
});
|
|
1428
|
+
}
|
|
1429
|
+
const artifact = await this.media.save({
|
|
1430
|
+
data: downloaded.data,
|
|
1431
|
+
mediaType,
|
|
1432
|
+
...(options.name === undefined ? {} : { name: options.name }),
|
|
1433
|
+
});
|
|
1434
|
+
if (operationAborted()) {
|
|
1435
|
+
await this.media.delete(artifact.artifactId).catch(() => undefined);
|
|
1436
|
+
throw new GrokImagineError("TIMEOUT", "Imagine video persistence was aborted", {
|
|
1437
|
+
cause: options.signal?.reason,
|
|
1438
|
+
});
|
|
1439
|
+
}
|
|
1440
|
+
const result: ImagineVideoStatusResult = {
|
|
1441
|
+
requestId,
|
|
1442
|
+
status: "completed",
|
|
1443
|
+
artifact,
|
|
1444
|
+
path: imagineMediaPath(artifact.artifactId),
|
|
1445
|
+
};
|
|
1446
|
+
this.rememberVideo(requestId, result);
|
|
1447
|
+
return result;
|
|
1448
|
+
}
|
|
1449
|
+
|
|
1450
|
+
private rememberVideo(requestId: string, result: ImagineVideoStatusResult): void {
|
|
1451
|
+
this.videoResults.set(requestId, result);
|
|
1452
|
+
if (this.videoResults.size <= MAX_CACHED_VIDEO_JOBS) return;
|
|
1453
|
+
const oldest = this.videoResults.keys().next().value;
|
|
1454
|
+
if (oldest !== undefined) this.videoResults.delete(oldest);
|
|
1455
|
+
}
|
|
1456
|
+
|
|
1457
|
+
private async persistGeneratedImage(
|
|
1458
|
+
row: Record<string, unknown>,
|
|
1459
|
+
name: string | undefined,
|
|
1460
|
+
signal: AbortSignal,
|
|
1461
|
+
): Promise<ImaginePersistedImage> {
|
|
1462
|
+
const b64 = typeof row["b64_json"] === "string" ? row["b64_json"] : undefined;
|
|
1463
|
+
const url = typeof row["url"] === "string" ? row["url"] : undefined;
|
|
1464
|
+
const declaredMime = typeof row["mime_type"] === "string" ? parseContentType(row["mime_type"]) : undefined;
|
|
1465
|
+
let data: Uint8Array;
|
|
1466
|
+
let declared = declaredMime;
|
|
1467
|
+
if (b64 !== undefined) {
|
|
1468
|
+
data = decodeBase64(b64, this.imageMaxBytes);
|
|
1469
|
+
} else {
|
|
1470
|
+
if (url === undefined) {
|
|
1471
|
+
throw new GrokImagineError("UPSTREAM", "Imagine image row did not include url or b64_json");
|
|
1472
|
+
}
|
|
1473
|
+
if (signal.aborted) {
|
|
1474
|
+
throw new GrokImagineError("TIMEOUT", "image download aborted before request", { cause: signal.reason });
|
|
1475
|
+
}
|
|
1476
|
+
const downloaded = await this.downloader.download({
|
|
1477
|
+
url,
|
|
1478
|
+
timeoutMs: this.imageDownloadTimeoutMs,
|
|
1479
|
+
maxBytes: this.imageMaxBytes,
|
|
1480
|
+
accept: "image/png,image/jpeg,image/webp,image/gif",
|
|
1481
|
+
signal,
|
|
1482
|
+
});
|
|
1483
|
+
data = downloaded.data;
|
|
1484
|
+
declared = downloaded.contentType ?? declared;
|
|
1485
|
+
}
|
|
1486
|
+
if (signal.aborted) {
|
|
1487
|
+
throw new GrokImagineError("TIMEOUT", "image download aborted before persistence", { cause: signal.reason });
|
|
1488
|
+
}
|
|
1489
|
+
if (data.byteLength > this.imageMaxBytes) {
|
|
1490
|
+
throw new GrokImagineError("MEDIA", `Imagine image exceeds the ${this.imageMaxBytes} byte ceiling`);
|
|
1491
|
+
}
|
|
1492
|
+
const mediaType = resolveImageMediaType(data, declared);
|
|
1493
|
+
const allowed = this.attachments.imageLimits?.mediaTypes;
|
|
1494
|
+
if (allowed !== undefined && !allowed.includes(mediaType)) {
|
|
1495
|
+
throw new GrokImagineError("MEDIA", `Imagine image type ${mediaType} is not accepted by the attachment store`);
|
|
1496
|
+
}
|
|
1497
|
+
if (this.disposed) {
|
|
1498
|
+
throw new GrokImagineError("INVALID_INPUT", "Imagine client was disposed before attachment persist");
|
|
1499
|
+
}
|
|
1500
|
+
const attachment = await this.attachments.saveImage({
|
|
1501
|
+
data,
|
|
1502
|
+
mediaType,
|
|
1503
|
+
...(name === undefined ? {} : { name }),
|
|
1504
|
+
});
|
|
1505
|
+
if (signal.aborted || this.disposed) {
|
|
1506
|
+
// AttachmentStore exposes no delete/cancellation seam; fail closed and do
|
|
1507
|
+
// not publish the now-orphaned immutable reference.
|
|
1508
|
+
throw new GrokImagineError("TIMEOUT", "Imagine attachment persistence was aborted", {
|
|
1509
|
+
cause: signal.reason,
|
|
1510
|
+
});
|
|
1511
|
+
}
|
|
1512
|
+
if (!isSafeImagineAttachmentId(attachment.attachmentId)) {
|
|
1513
|
+
throw new GrokImagineError("MEDIA", "attachment store returned an unsafe identifier");
|
|
1514
|
+
}
|
|
1515
|
+
return {
|
|
1516
|
+
attachment,
|
|
1517
|
+
path: imagineImagePath(attachment.attachmentId),
|
|
1518
|
+
};
|
|
1519
|
+
}
|
|
1520
|
+
|
|
1521
|
+
private async resolveCredential(operation: ImagineOperation): Promise<string> {
|
|
1522
|
+
try {
|
|
1523
|
+
return await this.resolveApiKey(operation);
|
|
1524
|
+
} catch (error) {
|
|
1525
|
+
if (error instanceof GrokImagineError) throw error;
|
|
1526
|
+
const detail = redactImagineMessage(error);
|
|
1527
|
+
throw new GrokImagineError(
|
|
1528
|
+
"MISSING_CREDENTIAL",
|
|
1529
|
+
`xAI API key resolver failed for ${operation} (${detail}). Grok Imagine does not fall back to OAuth.`,
|
|
1530
|
+
{ cause: error },
|
|
1531
|
+
);
|
|
1532
|
+
}
|
|
1533
|
+
}
|
|
1534
|
+
|
|
1535
|
+
private async apiJson(
|
|
1536
|
+
method: "GET" | "POST",
|
|
1537
|
+
path: string,
|
|
1538
|
+
apiKey: string,
|
|
1539
|
+
body: Record<string, unknown> | undefined,
|
|
1540
|
+
signal?: AbortSignal,
|
|
1541
|
+
): Promise<Record<string, unknown>> {
|
|
1542
|
+
const url = new URL(path, XAI_API_ORIGIN);
|
|
1543
|
+
if (url.origin !== XAI_API_ORIGIN) {
|
|
1544
|
+
throw new GrokImagineError("SSRF", "Imagine API URL escaped https://api.x.ai");
|
|
1545
|
+
}
|
|
1546
|
+
const headers: Record<string, string> = {
|
|
1547
|
+
Authorization: `Bearer ${apiKey}`,
|
|
1548
|
+
Accept: "application/json",
|
|
1549
|
+
};
|
|
1550
|
+
const composed = AbortSignal.any(
|
|
1551
|
+
signal === undefined
|
|
1552
|
+
? [AbortSignal.timeout(this.apiTimeoutMs), this.disposeController.signal]
|
|
1553
|
+
: [signal, AbortSignal.timeout(this.apiTimeoutMs), this.disposeController.signal],
|
|
1554
|
+
);
|
|
1555
|
+
const init: RequestInit = {
|
|
1556
|
+
method,
|
|
1557
|
+
headers,
|
|
1558
|
+
redirect: "error",
|
|
1559
|
+
signal: composed,
|
|
1560
|
+
};
|
|
1561
|
+
if (body !== undefined) {
|
|
1562
|
+
headers["Content-Type"] = "application/json";
|
|
1563
|
+
init.body = JSON.stringify(body);
|
|
1564
|
+
}
|
|
1565
|
+
let response: Response;
|
|
1566
|
+
try {
|
|
1567
|
+
response = await this.apiFetch(url, init);
|
|
1568
|
+
if (composed.aborted) {
|
|
1569
|
+
throw new GrokImagineError("TIMEOUT", "Imagine API request was aborted", { cause: composed.reason });
|
|
1570
|
+
}
|
|
1571
|
+
} catch (error) {
|
|
1572
|
+
if (error instanceof GrokImagineError) throw error;
|
|
1573
|
+
const name = error instanceof Error ? error.name : "";
|
|
1574
|
+
if (name === "TimeoutError" || name === "AbortError" || signal?.aborted === true) {
|
|
1575
|
+
throw new GrokImagineError("TIMEOUT", "Imagine API request was aborted", { cause: error });
|
|
1576
|
+
}
|
|
1577
|
+
throw new GrokImagineError("UPSTREAM", `Imagine API request failed (${redactImagineMessage(error)})`, {
|
|
1578
|
+
cause: error,
|
|
1579
|
+
});
|
|
1580
|
+
}
|
|
1581
|
+
const bytes = await readLimitedBody(response, this.apiJsonMaxBytes, composed);
|
|
1582
|
+
const text = new TextDecoder().decode(bytes);
|
|
1583
|
+
if (!response.ok) throw mapUpstreamStatus(response.status, text);
|
|
1584
|
+
if (text.trim() === "") return {};
|
|
1585
|
+
let parsed: unknown;
|
|
1586
|
+
try {
|
|
1587
|
+
parsed = JSON.parse(text);
|
|
1588
|
+
} catch (error) {
|
|
1589
|
+
throw new GrokImagineError("UPSTREAM", "Imagine API returned non-JSON", { cause: error });
|
|
1590
|
+
}
|
|
1591
|
+
if (!isRecord(parsed)) throw new GrokImagineError("UPSTREAM", "Imagine API returned a non-object JSON body");
|
|
1592
|
+
return parsed;
|
|
1593
|
+
}
|
|
1594
|
+
}
|
|
1595
|
+
|
|
1596
|
+
function cloneVideoStatus(result: ImagineVideoStatusResult): ImagineVideoStatusResult {
|
|
1597
|
+
const clone: ImagineVideoStatusResult = { requestId: result.requestId, status: result.status };
|
|
1598
|
+
if (result.artifact !== undefined) clone.artifact = { ...result.artifact };
|
|
1599
|
+
if (result.path !== undefined) clone.path = result.path;
|
|
1600
|
+
if (result.error !== undefined) clone.error = result.error;
|
|
1601
|
+
return clone;
|
|
1602
|
+
}
|
|
1603
|
+
|
|
1604
|
+
export function createGrokImagineClient(options: GrokImagineClientOptions): GrokImagineClient {
|
|
1605
|
+
return new GrokImagineClient(options);
|
|
1606
|
+
}
|
|
1607
|
+
|
|
1608
|
+
/** Gate a same-origin image download. Requires an explicit trusted/authz token. */
|
|
1609
|
+
export async function openTrustedImagineImageDownload(
|
|
1610
|
+
attachments: ImagineAttachmentStore,
|
|
1611
|
+
ref: ImagineImageAttachmentRef,
|
|
1612
|
+
authz: TrustedImagineAuthz,
|
|
1613
|
+
): Promise<ImagineImageDownloadView> {
|
|
1614
|
+
assertTrustedImagineAuthz(authz);
|
|
1615
|
+
if (!isSafeImagineAttachmentId(ref.attachmentId)) {
|
|
1616
|
+
throw new GrokImagineError("INVALID_INPUT", "attachment id is not safe for a same-origin image route");
|
|
1617
|
+
}
|
|
1618
|
+
if (attachments.readImage === undefined) {
|
|
1619
|
+
throw new GrokImagineError("MEDIA", "attachment store does not expose readImage");
|
|
1620
|
+
}
|
|
1621
|
+
const stored = await attachments.readImage(ref);
|
|
1622
|
+
return {
|
|
1623
|
+
ref: stored.ref,
|
|
1624
|
+
body: stored.data,
|
|
1625
|
+
headers: imagineImageDownloadHeaders(stored.ref),
|
|
1626
|
+
};
|
|
1627
|
+
}
|