ima2-gen 3.13.1 → 3.14.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/.env.example +10 -0
- package/CHANGELOG.md +14 -0
- package/README.md +15 -2
- package/bin/commands/capabilities.js +5 -3
- package/bin/commands/config.js +15 -4
- package/bin/commands/defaults.js +7 -5
- package/bin/commands/doctor.js +88 -39
- package/bin/commands/gen.js +21 -4
- package/bin/commands/models.js +2 -2
- package/bin/commands/ping.js +1 -1
- package/bin/commands/prompt.js +5 -3
- package/bin/commands/service.js +32 -4
- package/bin/commands/tools.js +19 -3
- package/bin/commands/upscale.js +18 -4
- package/bin/commands/video.js +37 -50
- package/bin/ima2.js +28 -9
- package/bin/lib/characterResolve.js +2 -1
- package/bin/lib/client.js +155 -20
- package/bin/lib/config-store.js +40 -7
- package/bin/lib/doctor-bundle.js +6 -2
- package/bin/lib/doctor-checks.js +13 -29
- package/bin/lib/doctor-media.js +3 -3
- package/bin/lib/doctor-providers.js +48 -35
- package/bin/lib/doctor-report.js +78 -0
- package/bin/lib/doctor-runtime.js +173 -0
- package/bin/lib/mcpJob.js +19 -7
- package/bin/lib/output.js +3 -0
- package/bin/lib/platform.js +12 -9
- package/bin/lib/recover-output.js +9 -2
- package/bin/lib/sse.js +9 -1
- package/bin/lib/videoMcp.js +16 -8
- package/config.js +41 -5
- package/docs/API.md +154 -16
- package/docs/CLI.md +65 -1
- package/docs/DOCKER.md +19 -4
- package/docs/DOCKER.zh-CN.md +11 -4
- package/docs/DOCKER.zh-TW.md +11 -4
- package/docs/FAQ.md +13 -0
- package/docs/NPX_QUICKSTART.md +6 -0
- package/docs/README.ja.md +15 -2
- package/docs/README.ko.md +15 -2
- package/docs/README.zh-CN.md +15 -2
- package/docs/README.zh-TW.md +15 -2
- package/docs/migration/runtime-test-inventory.md +79 -8
- package/lib/agyArtifact.js +82 -0
- package/lib/agyArtifactRead.js +249 -0
- package/lib/agyImageAdapter.js +2 -329
- package/lib/agyProcess.js +122 -0
- package/lib/apiRequestBudget.js +48 -0
- package/lib/assetLifecycle.js +42 -9
- package/lib/canvasVersionStore.js +46 -14
- package/lib/capabilities.js +7 -1
- package/lib/configKeys.js +2 -0
- package/lib/errors/providerMap.js +26 -0
- package/lib/eventBus.js +10 -6
- package/lib/eventsPolicy.js +9 -0
- package/lib/geminiApiImageAdapter.js +1 -214
- package/lib/generatePipeline.js +39 -165
- package/lib/generatedMediaAccess.js +74 -0
- package/lib/generationErrors.js +3 -22
- package/lib/grokImageAdapter.js +2 -353
- package/lib/grokImageCore.js +1 -57
- package/lib/grokImageDownload.js +159 -0
- package/lib/grokImageDownloadPolicy.js +124 -0
- package/lib/grokImagePlanner.js +301 -0
- package/lib/grokMultimodeAdapter.js +1 -75
- package/lib/grokVideoDownload.js +137 -34
- package/lib/inflight.js +112 -112
- package/lib/jobs/terminalStore.js +45 -0
- package/lib/lanSessionStore.js +131 -0
- package/lib/localAccessPolicy.js +180 -0
- package/lib/localLanAccess.js +281 -0
- package/lib/logger.js +22 -13
- package/lib/mcp/downloadMediaResult.js +72 -78
- package/lib/multimodePipeline.js +39 -126
- package/lib/nodeGeneration.js +44 -101
- package/lib/nodeStore.js +25 -11
- package/lib/nodeValidation.js +5 -1
- package/lib/pinnedHttpGet.js +174 -0
- package/lib/promptImport/githubFolder.js +32 -18
- package/lib/promptImport/githubSource.js +14 -10
- package/lib/promptImport/parsePromptCandidates.js +32 -2
- package/lib/providers/adapters/agyOperations.js +179 -0
- package/lib/providers/adapters/comfy.js +1 -0
- package/lib/providers/adapters/geminiOperations.js +214 -0
- package/lib/providers/adapters/googleExecution.js +97 -0
- package/lib/providers/adapters/grokExecution.js +137 -0
- package/lib/providers/adapters/grokMultimodeOperations.js +83 -0
- package/lib/providers/adapters/grokOperations.js +63 -0
- package/lib/providers/adapters/openaiExecution.js +118 -0
- package/lib/providers/adapters/openaiOperations.js +201 -0
- package/lib/providers/adapters/openaiTypes.js +1 -0
- package/lib/providers/derive.js +4 -0
- package/lib/providers/deriveCore.js +2 -7
- package/lib/providers/execution/admission.js +25 -0
- package/lib/providers/execution/index.js +34 -0
- package/lib/providers/execution/legacy.js +17 -0
- package/lib/providers/execution/legacyClassic.js +66 -0
- package/lib/providers/execution/legacyEdit.js +30 -0
- package/lib/providers/execution/legacyMultimode.js +34 -0
- package/lib/providers/execution/legacyNode.js +41 -0
- package/lib/providers/execution/types.js +1 -0
- package/lib/providers/registry.js +19 -14
- package/lib/providers/surfaceSupport.js +22 -0
- package/lib/responsesImageAdapter.js +1 -389
- package/lib/responsesTransport.js +190 -0
- package/lib/spriteJobEvents.js +20 -2
- package/lib/ssePublish.js +5 -4
- package/lib/videoExtendI2vOperation.js +76 -0
- package/package.json +5 -2
- package/routes/edit.js +31 -123
- package/routes/events.js +140 -63
- package/routes/mcpMedia.js +10 -2
- package/routes/models.js +28 -25
- package/routes/videoExtended.js +9 -68
- package/server.js +24 -46
- package/ui/dist/.vite/manifest.json +90 -63
- package/ui/dist/assets/{AgentWorkspace-PbBytTeV.js → AgentWorkspace-CL3MLv3l.js} +2 -2
- package/ui/dist/assets/App-B9cEM6dJ.js +9 -0
- package/ui/dist/assets/AssetGenWorkspace-swWPvXrx.js +2 -0
- package/ui/dist/assets/AssetsWorkspace-CRVeYU2F.js +1 -0
- package/ui/dist/assets/{CardNewsWorkspace-BS3jKOja.js → CardNewsWorkspace-BAOxWLKf.js} +2 -2
- package/ui/dist/assets/GenerationRequestLogPanel-DZtYZm-D.js +1 -0
- package/ui/dist/assets/HomeWorkspace-BeZ1s0Jm.js +1 -0
- package/ui/dist/assets/KeyingPanel-ZyBSXioh.js +1 -0
- package/ui/dist/assets/NodeCanvas-BvvcCErf.js +7 -0
- package/ui/dist/assets/{PromptBuilderPanel-D97A4pwB.js → PromptBuilderPanel-DBycmShA.js} +2 -2
- package/ui/dist/assets/PromptImportDialog-ChvMv7ek.js +2 -0
- package/ui/dist/assets/PromptImportDiscoverySection-60py7whH.js +1 -0
- package/ui/dist/assets/PromptImportFolderSection-CCRh_-_7.js +1 -0
- package/ui/dist/assets/PromptLibraryPanel-BFWQO8OT.js +2 -0
- package/ui/dist/assets/SettingsWorkspace-B5Ghsgdj.js +1 -0
- package/ui/dist/assets/SpriteRecipeWorkspace-DRWdkmwU.js +1 -0
- package/ui/dist/assets/index-7ngEOVxt.js +5 -0
- package/ui/dist/assets/index-B5jy7ePg.css +1 -0
- package/ui/dist/assets/index-gxh_02DQ.js +22 -0
- package/ui/dist/assets/{pptxgen.es-D4pedbwM.js → pptxgen.es-OUNhQLEV.js} +1 -1
- package/ui/dist/assets/promptBuilderStore-BTUUzbBT.js +1 -0
- package/ui/dist/assets/useAgentDialogFocus-DSc2qY8p.js +1 -0
- package/ui/dist/index.html +3 -2
- package/ui/dist/assets/AssetGenWorkspace-B-TGlvVb.js +0 -2
- package/ui/dist/assets/AssetsWorkspace-LnGaW151.js +0 -1
- package/ui/dist/assets/GenerationRequestLogPanel-DuDWD_ic.js +0 -1
- package/ui/dist/assets/HomeWorkspace-DVP7UG6O.js +0 -1
- package/ui/dist/assets/KeyingPanel-5bRUFQaq.js +0 -1
- package/ui/dist/assets/NodeCanvas-BBEpERTb.js +0 -7
- package/ui/dist/assets/PromptImportDialog-C89MN5JY.js +0 -2
- package/ui/dist/assets/PromptImportDiscoverySection-BgRfDOs2.js +0 -1
- package/ui/dist/assets/PromptImportFolderSection-zTMyriT1.js +0 -1
- package/ui/dist/assets/PromptLibraryPanel-BD96qZu_.js +0 -2
- package/ui/dist/assets/SettingsWorkspace-BAOk3X_8.js +0 -1
- package/ui/dist/assets/SpriteRecipeWorkspace-ClIM5QRo.js +0 -1
- package/ui/dist/assets/index-0fpfY8vu.css +0 -1
- package/ui/dist/assets/index-BFciPGTn.js +0 -5
- package/ui/dist/assets/index-BbykeXR9.js +0 -30
- package/ui/dist/assets/promptBuilderStore-Do7M8g7J.js +0 -1
- package/ui/dist/assets/useAgentDialogFocus-B6-QgEVI.js +0 -1
package/lib/grokImageAdapter.js
CHANGED
|
@@ -1,354 +1,3 @@
|
|
|
1
|
-
import { logEvent } from "./logger.js";
|
|
2
|
-
import { SAFETY_INTENT_POLICY } from "./promptSafetyPolicy.js";
|
|
3
|
-
import { mapSizeToGrokImageParams } from "./grokSizeMapper.js";
|
|
4
|
-
import { detectImageMimeFromB64 } from "./refs.js";
|
|
5
|
-
import { DEFAULT_GROK_PLANNER_MODEL } from "../config.js";
|
|
6
|
-
import { grokError, grokStageError, getPlannerConfig, getGrokEndpoint, withTimeoutSignal, imagePayload, imageEditPayload, referenceImageUrl, extractResponsesText, postGrokImages, downloadGrokImageUrl, } from "./grokImageCore.js";
|
|
7
|
-
import { grokFetchWithRetry } from "./grokUpstreamRetry.js";
|
|
8
1
|
export { grokError, imagePayload, imageEditPayload, postGrokImages, downloadGrokImageUrl, } from "./grokImageCore.js";
|
|
9
|
-
export
|
|
10
|
-
|
|
11
|
-
const referenceCount = Array.isArray(references) ? references.length : references;
|
|
12
|
-
const sizeLine = size
|
|
13
|
-
? `Requested ima2 size: ${size}; xAI parameters: ${JSON.stringify(sizeParams)}.`
|
|
14
|
-
: `Requested ima2 size: auto; xAI parameters: ${JSON.stringify(sizeParams)}.`;
|
|
15
|
-
const referenceLine = referenceCount > 0
|
|
16
|
-
? `Reference images attached: ${referenceCount}. The final image call will use xAI image editing with these source images; preserve relevant subject, composition, style, and product details from them.`
|
|
17
|
-
: "Reference images attached: 0. The final image call will use text-to-image generation.";
|
|
18
|
-
return {
|
|
19
|
-
model: plannerModel,
|
|
20
|
-
stream: false,
|
|
21
|
-
parallel_tool_calls: false,
|
|
22
|
-
messages: [
|
|
23
|
-
{
|
|
24
|
-
role: "system",
|
|
25
|
-
content: [
|
|
26
|
-
"You are ima2's image generation planner for xAI Grok Imagine (Aurora model), inside a professional creative tool used by designers and artists. Users create content for legitimate creative, commercial, or educational purposes.",
|
|
27
|
-
SAFETY_INTENT_POLICY,
|
|
28
|
-
"",
|
|
29
|
-
"TASK: Rewrite the user's casual request into ONE optimal, production-ready image prompt.",
|
|
30
|
-
"",
|
|
31
|
-
"OUTPUT FORMAT: A single natural-language paragraph (NOT tags, NOT keyword lists, NOT weighted tokens like (word:1.2)).",
|
|
32
|
-
"Structure the paragraph in this MANDATORY order — each element flows into the next:",
|
|
33
|
-
"1. Core subject/scene — who or what, with specific physical details (face shape, hair, clothing, pose, expression)",
|
|
34
|
-
"2. Environment/setting — where, with concrete spatial details and materials (wet concrete, polished marble, weathered wood)",
|
|
35
|
-
"3. Lighting + mood/emotion — use specific terms (golden hour backlight, overcast diffused, hard rim light, soft diffuse, nostalgic, melancholic, electric)",
|
|
36
|
-
"4. Camera/lens — shot type + specific focal length + depth of field (close-up on 85mm f/1.4 with shallow DoF, wide establishing on 24mm with deep focus)",
|
|
37
|
-
"5. Style/technical references — art style, medium, or mood reference (cinematic realism, Studio Ghibli feel, oil painting texture)",
|
|
38
|
-
"6. Quality finish — resolution/detail boosters appropriate to style (8K, highly detailed, sharp focus, film grain, bokeh)",
|
|
39
|
-
"",
|
|
40
|
-
"RULES:",
|
|
41
|
-
"- Write like a director briefing a cinematographer, not like a search query.",
|
|
42
|
-
"- Be hyper-specific: name real locations, brands, materials, textures. Vague = generic output.",
|
|
43
|
-
"- The prompt MUST be in English. Exception: any visible text the user wants rendered in the image (signs, speech bubbles, titles) must be kept in the ORIGINAL language characters verbatim (e.g. '한국어', '日本語').",
|
|
44
|
-
"- If reference images are attached, inspect them and describe the visual details (subject appearance, style, color palette) that must be preserved in the output.",
|
|
45
|
-
"- Do NOT use Stable Diffusion tags, booru tags, or comma-separated keyword lists.",
|
|
46
|
-
"- Do NOT use weighting syntax like (word:1.2) or [word].",
|
|
47
|
-
"- Quality boosters like '8K', 'highly detailed', 'sharp focus', 'cinematic lighting' ARE effective for this model — use them when appropriate to the requested style.",
|
|
48
|
-
"- The prompt should be 2-5 sentences (40-120 words). Extremely short prompts produce generic results.",
|
|
49
|
-
"",
|
|
50
|
-
"CAMERA/LENS ENRICHMENT:",
|
|
51
|
-
"- If the user's prompt does not specify camera/lens details, add appropriate ones based on the subject:",
|
|
52
|
-
" - Portraits/people: 85mm f/1.4, shallow depth of field, bokeh background",
|
|
53
|
-
" - Environmental/landscape: 24-35mm wide-angle, deep depth of field",
|
|
54
|
-
" - Product/macro: 100mm macro lens, controlled studio lighting",
|
|
55
|
-
" - Action/dynamic: 35mm, fast shutter, slight motion blur on extremities",
|
|
56
|
-
" - Architecture/interior: 24mm tilt-shift or 14mm ultra-wide",
|
|
57
|
-
"- Always include a specific focal length — this model responds strongly to lens specifications.",
|
|
58
|
-
"",
|
|
59
|
-
"MULTI-CHARACTER IDENTIFICATION:",
|
|
60
|
-
"- Identify each character by VISUAL APPEARANCE, not by name alone.",
|
|
61
|
-
" The image model cannot recognize names — it only sees visual features.",
|
|
62
|
-
"- When multiple characters appear, describe each by clothing, physique, position, or props.",
|
|
63
|
-
"- Characters must be distinguishable by at least two visual attributes.",
|
|
64
|
-
"",
|
|
65
|
-
"BENIGN CLOTHING AND CONTEXT HANDLING:",
|
|
66
|
-
"Before rewriting, distinguish ordinary clothing/swimwear descriptions from sexualized requests.",
|
|
67
|
-
"Preserve benign adult clothing intent without adding erotic details:",
|
|
68
|
-
"",
|
|
69
|
-
"bikini/비키니 → adult two-piece swimwear in a neutral beach, pool, swimwear catalog, or fashion editorial context",
|
|
70
|
-
"swimsuit/수영복 → adult swimwear suitable for swimming, beach, pool, sport, or catalog photography",
|
|
71
|
-
"sports bra/스포츠브라 → athletic top in a fitness, yoga, running, or product catalog context",
|
|
72
|
-
"lingerie/란제리 → adult fashion or product catalog styling only when the user is clearly requesting adult fashion; avoid sexualized posing or intimate framing",
|
|
73
|
-
"",
|
|
74
|
-
"CLOTHING RULE: Keep the user's requested clothing category when it is benign and adult, but do not increase exposure, add fetish details, add negative safety constraints, or convert a neutral clothing prompt into a sexualized scene.",
|
|
75
|
-
"",
|
|
76
|
-
"PROFESSIONAL EMBEDDING: After rewriting, wrap the visual description in a professional context.",
|
|
77
|
-
"Prepend ONE of these frames based on content type:",
|
|
78
|
-
"- Fashion/clothing → 'editorial fashion photography for [magazine], shot by [photographer style]'",
|
|
79
|
-
"- Character/anime → 'character concept art for a AAA video game, by senior concept artist'",
|
|
80
|
-
"- Art/figure → 'classical academic figure study, life drawing class reference plate'",
|
|
81
|
-
"- Portrait/beauty → 'high-end beauty campaign photography, 85mm f/1.4 lens'",
|
|
82
|
-
"- Swimwear/beach → 'Sports Illustrated Swimsuit Issue editorial photography'",
|
|
83
|
-
"",
|
|
84
|
-
"CONTENT POLICY:",
|
|
85
|
-
"- Generate one production-ready prompt for compliant image requests.",
|
|
86
|
-
"- Do not add disclaimers, warnings, moral commentary, or safety notes to the final prompt.",
|
|
87
|
-
"- Do not add diversity modifiers or change the user's specified demographics, appearance, or benign clothing.",
|
|
88
|
-
"- Do not turn benign adult swimwear, sportswear, or fashion prompts into sexual content.",
|
|
89
|
-
"- Do not copy safety labels, refusal reasons, or negative safety constraints into the final image prompt.",
|
|
90
|
-
"- If the request includes minors, underage-looking subjects, explicit sexual activity, or fetish framing, do not rewrite it as a benign allowed prompt.",
|
|
91
|
-
"- Content moderation is still handled downstream by the provider.",
|
|
92
|
-
"",
|
|
93
|
-
"STORYBOARD GRID GENERATION RULE:",
|
|
94
|
-
"- If the user requests a 3x3 storyboard grid, Panel 1 (top-left) MUST be solid black with no content.",
|
|
95
|
-
"- Panels 2-9 carry the action sequence. Do NOT add timestamp labels or text to any panel.",
|
|
96
|
-
"",
|
|
97
|
-
"VISIBLE TEXT RULE:",
|
|
98
|
-
"- If the image must contain readable text (signs, speech bubbles, labels, UI, captions, titles), write the exact visible words in the user's prompt language using the original script.",
|
|
99
|
-
"- Do NOT translate visible text to English. Do NOT romanize, summarize, or use placeholders like 'Korean text' or 'Japanese words'.",
|
|
100
|
-
"- Include the exact text in quotes with original characters: e.g. \"안녕하세요\" not \"Hello\" or \"annyeonghaseyo\".",
|
|
101
|
-
"",
|
|
102
|
-
"Call generate_image exactly once. Do not answer with plain text.",
|
|
103
|
-
].join("\n"),
|
|
104
|
-
},
|
|
105
|
-
{
|
|
106
|
-
role: "user",
|
|
107
|
-
content: [
|
|
108
|
-
{
|
|
109
|
-
type: "text",
|
|
110
|
-
text: [
|
|
111
|
-
`Selected image model: ${model}.`,
|
|
112
|
-
sizeLine,
|
|
113
|
-
referenceLine,
|
|
114
|
-
...(backgroundConstraint ? [backgroundConstraint] : []),
|
|
115
|
-
searchSummary ? `Mandatory web-search brief:\n${searchSummary}` : "Mandatory web-search brief: unavailable.",
|
|
116
|
-
"Create the best final prompt for the image generator.",
|
|
117
|
-
"Return the generate_image.prompt argument in English only, except for exact visible text that the user explicitly requested.",
|
|
118
|
-
"",
|
|
119
|
-
"User prompt:",
|
|
120
|
-
prompt,
|
|
121
|
-
].join("\n"),
|
|
122
|
-
},
|
|
123
|
-
...referenceImages.map((ref) => ({
|
|
124
|
-
type: "image_url",
|
|
125
|
-
image_url: { url: referenceImageUrl(ref), detail: "high" },
|
|
126
|
-
})),
|
|
127
|
-
],
|
|
128
|
-
},
|
|
129
|
-
],
|
|
130
|
-
tools: [
|
|
131
|
-
{
|
|
132
|
-
type: "function",
|
|
133
|
-
function: {
|
|
134
|
-
name: "generate_image",
|
|
135
|
-
description: "Generate a single image through xAI Images API.",
|
|
136
|
-
parameters: {
|
|
137
|
-
type: "object",
|
|
138
|
-
properties: {
|
|
139
|
-
prompt: {
|
|
140
|
-
type: "string",
|
|
141
|
-
description: "Final image-generation prompt to send to xAI Images API.",
|
|
142
|
-
},
|
|
143
|
-
model: {
|
|
144
|
-
type: "string",
|
|
145
|
-
enum: ["grok-imagine-image-2.0", "grok-imagine-image", "grok-imagine-image-quality"],
|
|
146
|
-
description: "The xAI image model. The server may override this with the user's selected model.",
|
|
147
|
-
},
|
|
148
|
-
},
|
|
149
|
-
required: ["prompt", "model"],
|
|
150
|
-
},
|
|
151
|
-
},
|
|
152
|
-
},
|
|
153
|
-
],
|
|
154
|
-
tool_choice: { type: "function", function: { name: "generate_image" } },
|
|
155
|
-
};
|
|
156
|
-
}
|
|
157
|
-
export function buildGrokSearchPayload(prompt, plannerModel = DEFAULT_GROK_PLANNER_MODEL) {
|
|
158
|
-
return {
|
|
159
|
-
model: plannerModel,
|
|
160
|
-
stream: false,
|
|
161
|
-
input: [
|
|
162
|
-
{
|
|
163
|
-
role: "system",
|
|
164
|
-
content: [
|
|
165
|
-
"You are ima2's visual research assistant.",
|
|
166
|
-
"You must use web_search before producing the brief.",
|
|
167
|
-
"Return a concise image-generation research brief: visual facts, current references, style cues, and text-rendering constraints.",
|
|
168
|
-
"Do not generate an image prompt yet.",
|
|
169
|
-
].join(" "),
|
|
170
|
-
},
|
|
171
|
-
{
|
|
172
|
-
role: "user",
|
|
173
|
-
content: prompt,
|
|
174
|
-
},
|
|
175
|
-
],
|
|
176
|
-
tools: [{ type: "web_search" }],
|
|
177
|
-
tool_choice: "required",
|
|
178
|
-
};
|
|
179
|
-
}
|
|
180
|
-
export async function searchGrokVisualContext(prompt, ctx, options = {}) {
|
|
181
|
-
const planner = getPlannerConfig(ctx);
|
|
182
|
-
const plannerModel = options.plannerModel || planner.model;
|
|
183
|
-
const payload = buildGrokSearchPayload(prompt, plannerModel);
|
|
184
|
-
const { url, headers } = getGrokEndpoint(ctx, "/v1/responses", options.directApiKey);
|
|
185
|
-
// The search brief has its own, smaller budget: it runs BEFORE the planner call inside
|
|
186
|
-
// the same user request, so charging it the full planner budget lets one slow stage eat
|
|
187
|
-
// the other's time. See devlog/_plan/260817_grok_video_planner_timeout/010.
|
|
188
|
-
const { combinedSignal, timer } = withTimeoutSignal(options.signal, planner.searchTimeoutMs);
|
|
189
|
-
logEvent("grok", "search:start", { requestId: options.requestId, plannerModel, promptChars: prompt.length });
|
|
190
|
-
try {
|
|
191
|
-
// Safe to replay: search produces no billable artifact, only a research summary.
|
|
192
|
-
const res = await grokFetchWithRetry(() => fetch(url, {
|
|
193
|
-
method: "POST",
|
|
194
|
-
headers,
|
|
195
|
-
body: JSON.stringify(payload),
|
|
196
|
-
signal: combinedSignal,
|
|
197
|
-
}), { signal: combinedSignal, label: "search" });
|
|
198
|
-
clearTimeout(timer);
|
|
199
|
-
if (!res.ok) {
|
|
200
|
-
const text = await res.text().catch(() => "");
|
|
201
|
-
let parsed;
|
|
202
|
-
try {
|
|
203
|
-
parsed = JSON.parse(text);
|
|
204
|
-
}
|
|
205
|
-
catch { /* ignore */ }
|
|
206
|
-
const msg = parsed?.error || text || `HTTP ${res.status}`;
|
|
207
|
-
throw grokStageError("search", msg, res.status);
|
|
208
|
-
}
|
|
209
|
-
const summary = extractResponsesText(await res.json());
|
|
210
|
-
if (!summary)
|
|
211
|
-
throw grokError("Grok web search returned no research summary", 502, "GROK_SEARCH_EMPTY_RESPONSE");
|
|
212
|
-
logEvent("grok", "search:done", { requestId: options.requestId, plannerModel, summaryChars: summary.length });
|
|
213
|
-
return { summary };
|
|
214
|
-
}
|
|
215
|
-
catch (e) {
|
|
216
|
-
clearTimeout(timer);
|
|
217
|
-
if (e.name === "AbortError") {
|
|
218
|
-
if (options.signal?.aborted)
|
|
219
|
-
throw grokError("Generation canceled", 499, "GENERATION_CANCELED");
|
|
220
|
-
throw grokError("Grok web search timed out", 504, "GROK_SEARCH_TIMEOUT");
|
|
221
|
-
}
|
|
222
|
-
if (e.code && e.status)
|
|
223
|
-
throw e;
|
|
224
|
-
throw grokError(`Grok web search request failed: ${e.message}`, 502, "GROK_SEARCH_NETWORK_FAILED");
|
|
225
|
-
}
|
|
226
|
-
}
|
|
227
|
-
export function parseGrokImagePlan(response, fallbackModel) {
|
|
228
|
-
const toolCalls = response.choices?.[0]?.message?.tool_calls || [];
|
|
229
|
-
const call = toolCalls.find((item) => item.type === "function" && item.function?.name === "generate_image");
|
|
230
|
-
if (!call?.function?.arguments) {
|
|
231
|
-
throw grokError("Grok planner did not call generate_image", 502, "GROK_PLANNER_EMPTY_TOOL_CALL");
|
|
232
|
-
}
|
|
233
|
-
let args;
|
|
234
|
-
try {
|
|
235
|
-
args = JSON.parse(call.function.arguments);
|
|
236
|
-
}
|
|
237
|
-
catch {
|
|
238
|
-
throw grokError("Grok planner returned invalid tool arguments", 502, "GROK_PLANNER_INVALID_TOOL_ARGS");
|
|
239
|
-
}
|
|
240
|
-
if (typeof args?.prompt !== "string" || !args.prompt.trim()) {
|
|
241
|
-
throw grokError("Grok planner returned an empty image prompt", 502, "GROK_PLANNER_INVALID_TOOL_ARGS");
|
|
242
|
-
}
|
|
243
|
-
return { prompt: args.prompt.trim(), model: fallbackModel, webSearchCalls: 1 };
|
|
244
|
-
}
|
|
245
|
-
export async function planGrokImage(prompt, ctx, options = {}) {
|
|
246
|
-
const imageModel = options.model || ctx.config.grokProvider?.defaultImageModel || "grok-imagine-image-quality";
|
|
247
|
-
const planner = getPlannerConfig(ctx);
|
|
248
|
-
const plannerModel = options.plannerModel || planner.model;
|
|
249
|
-
const sizeParams = mapSizeToGrokImageParams(options.size);
|
|
250
|
-
// Honor the web-search toggle end to end: skip the forced search call and
|
|
251
|
-
// record calls actually made (070 QA: search ran even with
|
|
252
|
-
// webSearchEnabled:false, and webSearchCalls was hardcoded to 1).
|
|
253
|
-
const searchEnabled = options.webSearchEnabled !== false;
|
|
254
|
-
const search = searchEnabled
|
|
255
|
-
? await searchGrokVisualContext(prompt, ctx, { ...(options.signal ? { signal: options.signal } : {}), ...(options.requestId ? { requestId: options.requestId } : {}), ...(options.directApiKey ? { directApiKey: options.directApiKey } : {}), plannerModel })
|
|
256
|
-
: { summary: "", skipped: true };
|
|
257
|
-
const payload = buildGrokPlannerPayload(prompt, imageModel, options.size, sizeParams, plannerModel, search.summary, options.references || options.referenceCount || 0, options.backgroundConstraint || "");
|
|
258
|
-
const { url, headers } = getGrokEndpoint(ctx, "/v1/chat/completions", options.directApiKey);
|
|
259
|
-
const { combinedSignal, timer } = withTimeoutSignal(options.signal, planner.timeoutMs);
|
|
260
|
-
logEvent("grok", "planner:start", { requestId: options.requestId, plannerModel, imageModel, size: options.size });
|
|
261
|
-
try {
|
|
262
|
-
// Safe to replay: the planner produces no billable artifact, only a prompt.
|
|
263
|
-
const res = await grokFetchWithRetry(() => fetch(url, {
|
|
264
|
-
method: "POST",
|
|
265
|
-
headers,
|
|
266
|
-
body: JSON.stringify(payload),
|
|
267
|
-
signal: combinedSignal,
|
|
268
|
-
}), { signal: combinedSignal, label: "planner" });
|
|
269
|
-
clearTimeout(timer);
|
|
270
|
-
if (!res.ok) {
|
|
271
|
-
const text = await res.text().catch(() => "");
|
|
272
|
-
let parsed;
|
|
273
|
-
try {
|
|
274
|
-
parsed = JSON.parse(text);
|
|
275
|
-
}
|
|
276
|
-
catch { /* ignore */ }
|
|
277
|
-
const msg = parsed?.error || text || `HTTP ${res.status}`;
|
|
278
|
-
throw grokStageError("planner", msg, res.status);
|
|
279
|
-
}
|
|
280
|
-
const parsed = parseGrokImagePlan(await res.json(), imageModel);
|
|
281
|
-
const plan = { ...parsed, webSearchCalls: searchEnabled ? 1 : 0 };
|
|
282
|
-
logEvent("grok", "planner:done", {
|
|
283
|
-
requestId: options.requestId,
|
|
284
|
-
plannerModel,
|
|
285
|
-
imageModel,
|
|
286
|
-
promptChars: plan.prompt.length,
|
|
287
|
-
aspectRatio: sizeParams.aspect_ratio,
|
|
288
|
-
resolution: sizeParams.resolution,
|
|
289
|
-
});
|
|
290
|
-
return plan;
|
|
291
|
-
}
|
|
292
|
-
catch (e) {
|
|
293
|
-
clearTimeout(timer);
|
|
294
|
-
if (e.name === "AbortError") {
|
|
295
|
-
if (options.signal?.aborted)
|
|
296
|
-
throw grokError("Generation canceled", 499, "GENERATION_CANCELED");
|
|
297
|
-
throw grokError("Grok planner timed out", 504, "GROK_PLANNER_TIMEOUT");
|
|
298
|
-
}
|
|
299
|
-
if (e.code && e.status)
|
|
300
|
-
throw e;
|
|
301
|
-
throw grokError(`Grok planner request failed: ${e.message}`, 502, "GROK_PLANNER_NETWORK_FAILED");
|
|
302
|
-
}
|
|
303
|
-
}
|
|
304
|
-
export async function generateViaGrok(prompt, ctx, options = {}) {
|
|
305
|
-
const model = options.model || ctx.config.grokProvider?.defaultImageModel || "grok-imagine-image-quality";
|
|
306
|
-
const references = options.references || [];
|
|
307
|
-
const plan = options.plannedPrompt
|
|
308
|
-
? { prompt: options.plannedPrompt, model, webSearchCalls: options.webSearchCalls ?? 1 }
|
|
309
|
-
: await planGrokImage(prompt, ctx, { ...options, referenceCount: references.length, directApiKey: options.directApiKey });
|
|
310
|
-
const hasReferences = references.length > 0;
|
|
311
|
-
const payload = hasReferences
|
|
312
|
-
? imageEditPayload(model, plan.prompt, references, options.size)
|
|
313
|
-
: imagePayload(model, plan.prompt, options.size);
|
|
314
|
-
const endpoint = hasReferences ? "/v1/images/edits" : "/v1/images/generations";
|
|
315
|
-
const logStage = hasReferences ? "generate:edit-start" : "generate:start";
|
|
316
|
-
logEvent("grok", logStage, {
|
|
317
|
-
requestId: options.requestId,
|
|
318
|
-
model,
|
|
319
|
-
promptChars: plan.prompt.length,
|
|
320
|
-
size: options.size,
|
|
321
|
-
refs: references.length,
|
|
322
|
-
});
|
|
323
|
-
const result = await postGrokImages(ctx, payload, options.signal, endpoint, options.directApiKey);
|
|
324
|
-
const imageUrl = result.data?.[0]?.url;
|
|
325
|
-
if (!imageUrl) {
|
|
326
|
-
throw grokError("Grok returned no image URL", 502, "GROK_EMPTY_RESPONSE");
|
|
327
|
-
}
|
|
328
|
-
const downloaded = await downloadGrokImageUrl(imageUrl, options.signal);
|
|
329
|
-
const usage = result.usage ? { grok_cost_usd_ticks: result.usage.cost_in_usd_ticks ?? 0 } : null;
|
|
330
|
-
logEvent("grok", "generate:done", {
|
|
331
|
-
requestId: options.requestId,
|
|
332
|
-
model,
|
|
333
|
-
endpoint,
|
|
334
|
-
refs: references.length,
|
|
335
|
-
b64Len: downloaded.b64.length,
|
|
336
|
-
});
|
|
337
|
-
return { b64: downloaded.b64, providerUrl: imageUrl, usage, webSearchCalls: plan.webSearchCalls, mime: downloaded.mime, revisedPrompt: plan.prompt };
|
|
338
|
-
}
|
|
339
|
-
export async function editViaGrok(prompt, imageB64, ctx, options = {}) {
|
|
340
|
-
const model = options.model || ctx.config.grokProvider?.defaultImageModel || "grok-imagine-image-quality";
|
|
341
|
-
const detectedInputMime = detectImageMimeFromB64(imageB64) || "image/png";
|
|
342
|
-
const imageUrl = imageB64.startsWith("data:") ? imageB64 : `data:${detectedInputMime};base64,${imageB64}`;
|
|
343
|
-
const payload = { model, prompt, n: 1, response_format: "url", image: { type: "image_url", url: imageUrl }, ...mapSizeToGrokImageParams(options.size) };
|
|
344
|
-
logEvent("grok", "edit:start", { requestId: options.requestId, model, promptChars: prompt.length });
|
|
345
|
-
const result = await postGrokImages(ctx, payload, options.signal, "/v1/images/edits", options.directApiKey);
|
|
346
|
-
const editResultUrl = result.data?.[0]?.url;
|
|
347
|
-
if (!editResultUrl) {
|
|
348
|
-
throw grokError("Grok edit returned no image URL", 502, "GROK_EMPTY_RESPONSE");
|
|
349
|
-
}
|
|
350
|
-
const downloaded = await downloadGrokImageUrl(editResultUrl, options.signal);
|
|
351
|
-
const usage = result.usage ? { grok_cost_usd_ticks: result.usage.cost_in_usd_ticks ?? 0 } : null;
|
|
352
|
-
logEvent("grok", "edit:done", { requestId: options.requestId, model, b64Len: downloaded.b64.length });
|
|
353
|
-
return { b64: downloaded.b64, providerUrl: editResultUrl, usage, webSearchCalls: 0, mime: downloaded.mime, revisedPrompt: result.data[0]?.revised_prompt || prompt };
|
|
354
|
-
}
|
|
2
|
+
export { buildGrokPlannerPayload, buildGrokSearchPayload, searchGrokVisualContext, parseGrokImagePlan, planGrokImage, } from "./grokImagePlanner.js";
|
|
3
|
+
export { generateViaGrok, editViaGrok } from "./providers/adapters/grokOperations.js";
|
package/lib/grokImageCore.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { mapSizeToGrokImageParams } from "./grokSizeMapper.js";
|
|
2
2
|
import { detectImageMimeFromB64 } from "./refs.js";
|
|
3
3
|
import { getGrokProxyUrl } from "./grokRuntime.js";
|
|
4
|
-
|
|
4
|
+
export { downloadGrokImageUrl } from "./grokImageDownload.js";
|
|
5
5
|
import { DEFAULT_GROK_PLANNER_MODEL } from "../config.js";
|
|
6
6
|
export function getGrokEndpoint(ctx, path = "/v1/images/generations", directApiKey) {
|
|
7
7
|
if (directApiKey) {
|
|
@@ -76,62 +76,6 @@ export function extractResponsesText(response) {
|
|
|
76
76
|
}
|
|
77
77
|
return chunks.join("\n\n").trim();
|
|
78
78
|
}
|
|
79
|
-
const MAX_IMAGE_DOWNLOAD_BYTES = 50 * 1024 * 1024;
|
|
80
|
-
export async function downloadGrokImageUrl(url, signal, timeoutMs = 30_000) {
|
|
81
|
-
const controller = new AbortController();
|
|
82
|
-
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
83
|
-
const combined = signal ? AbortSignal.any([signal, controller.signal]) : controller.signal;
|
|
84
|
-
try {
|
|
85
|
-
const parsed = new URL(url);
|
|
86
|
-
if (parsed.protocol !== "https:" && parsed.protocol !== "http:") {
|
|
87
|
-
throw grokError("Image download URL must be HTTP(S)", 502, "GROK_IMAGE_DOWNLOAD_FAILED");
|
|
88
|
-
}
|
|
89
|
-
// Safe to replay: downloading a finished artifact creates nothing upstream.
|
|
90
|
-
const res = await grokFetchWithRetry(() => fetch(url, { signal: combined }), { signal: combined, label: "image-download" });
|
|
91
|
-
if (!res.ok)
|
|
92
|
-
throw grokError(`Image download failed: HTTP ${res.status}`, 502, "GROK_IMAGE_DOWNLOAD_FAILED");
|
|
93
|
-
const contentLength = Number(res.headers.get("content-length") || "0");
|
|
94
|
-
if (contentLength > MAX_IMAGE_DOWNLOAD_BYTES) {
|
|
95
|
-
throw grokError("Image download exceeds 50MB limit", 502, "GROK_IMAGE_DOWNLOAD_FAILED");
|
|
96
|
-
}
|
|
97
|
-
if (!res.body)
|
|
98
|
-
throw grokError("Image download had no response body", 502, "GROK_IMAGE_DOWNLOAD_FAILED");
|
|
99
|
-
const chunks = [];
|
|
100
|
-
let total = 0;
|
|
101
|
-
const reader = res.body.getReader();
|
|
102
|
-
while (true) {
|
|
103
|
-
const { done, value } = await reader.read();
|
|
104
|
-
if (done)
|
|
105
|
-
break;
|
|
106
|
-
total += value.byteLength;
|
|
107
|
-
if (total > MAX_IMAGE_DOWNLOAD_BYTES) {
|
|
108
|
-
await reader.cancel("download size limit exceeded").catch(() => { });
|
|
109
|
-
controller.abort();
|
|
110
|
-
throw grokError("Image download exceeds 50MB limit", 502, "GROK_IMAGE_DOWNLOAD_FAILED");
|
|
111
|
-
}
|
|
112
|
-
chunks.push(Buffer.from(value));
|
|
113
|
-
}
|
|
114
|
-
const buffer = Buffer.concat(chunks, total);
|
|
115
|
-
clearTimeout(timer);
|
|
116
|
-
if (buffer.length === 0)
|
|
117
|
-
throw grokError("Image download was empty", 502, "GROK_IMAGE_DOWNLOAD_FAILED");
|
|
118
|
-
const mime = res.headers.get("content-type")?.split(";")[0]?.trim()
|
|
119
|
-
|| detectImageMimeFromB64(buffer.toString("base64"))
|
|
120
|
-
|| "image/png";
|
|
121
|
-
return { buffer, b64: buffer.toString("base64"), mime };
|
|
122
|
-
}
|
|
123
|
-
catch (e) {
|
|
124
|
-
clearTimeout(timer);
|
|
125
|
-
if (e.name === "AbortError") {
|
|
126
|
-
if (signal?.aborted)
|
|
127
|
-
throw grokError("Generation canceled", 499, "GENERATION_CANCELED");
|
|
128
|
-
throw grokError("Image download timed out", 504, "GROK_IMAGE_TIMEOUT");
|
|
129
|
-
}
|
|
130
|
-
if (e.code && e.status)
|
|
131
|
-
throw e;
|
|
132
|
-
throw grokError(`Image download failed: ${e.message}`, 502, "GROK_IMAGE_DOWNLOAD_FAILED");
|
|
133
|
-
}
|
|
134
|
-
}
|
|
135
79
|
/**
|
|
136
80
|
* NOT wrapped in grokFetchWithRetry on purpose: a reset or 5xx here may follow an image the
|
|
137
81
|
* origin already generated and billed, and no idempotency key exists to deduplicate it.
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
import { pinnedHttpGet, PinnedGetFailure } from "./pinnedHttpGet.js";
|
|
2
|
+
import { detectImageMimeFromB64 } from "./refs.js";
|
|
3
|
+
import { grokFetchWithRetry } from "./grokUpstreamRetry.js";
|
|
4
|
+
import { resolveImageDownloadTarget, } from "./grokImageDownloadPolicy.js";
|
|
5
|
+
const MAX_IMAGE_DOWNLOAD_BYTES = 50 * 1024 * 1024;
|
|
6
|
+
const MAX_IMAGE_REDIRECTS = 5;
|
|
7
|
+
const IMAGE_REDIRECT_STATUSES = new Set([301, 302, 303, 307, 308]);
|
|
8
|
+
class ImageDownloadFailure extends Error {
|
|
9
|
+
status;
|
|
10
|
+
code;
|
|
11
|
+
constructor(message, status = 502, code = "GROK_IMAGE_DOWNLOAD_FAILED") {
|
|
12
|
+
super(message);
|
|
13
|
+
this.status = status;
|
|
14
|
+
this.code = code;
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
class ImageBodyFailure extends Error {
|
|
18
|
+
reason;
|
|
19
|
+
constructor(reason) {
|
|
20
|
+
super(reason);
|
|
21
|
+
this.reason = reason;
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
function cancelPinnedImageResponse(response) {
|
|
25
|
+
try {
|
|
26
|
+
void Promise.resolve(response.cancel()).catch(() => { });
|
|
27
|
+
}
|
|
28
|
+
catch { /* cleanup only */ }
|
|
29
|
+
return Promise.resolve(); // Never await an advisory cleanup promise.
|
|
30
|
+
}
|
|
31
|
+
function toRetryResponse(source) {
|
|
32
|
+
const headers = new Headers();
|
|
33
|
+
const retryAfter = source.headers.get("retry-after");
|
|
34
|
+
if (retryAfter !== null)
|
|
35
|
+
headers.set("retry-after", retryAfter);
|
|
36
|
+
return { ok: source.status >= 200 && source.status < 300, status: source.status,
|
|
37
|
+
headers, source, body: { cancel: () => cancelPinnedImageResponse(source) } };
|
|
38
|
+
}
|
|
39
|
+
async function fetchPinnedImageWithRetry(url, policy, signal) {
|
|
40
|
+
let active;
|
|
41
|
+
try {
|
|
42
|
+
const result = await grokFetchWithRetry(async () => {
|
|
43
|
+
const target = await resolveImageDownloadTarget(url, policy, signal);
|
|
44
|
+
signal.throwIfAborted();
|
|
45
|
+
active = await pinnedHttpGet(target, signal);
|
|
46
|
+
return toRetryResponse(active);
|
|
47
|
+
}, { signal, label: "image-download" });
|
|
48
|
+
signal.throwIfAborted();
|
|
49
|
+
return result.source;
|
|
50
|
+
}
|
|
51
|
+
catch (error) {
|
|
52
|
+
if (active)
|
|
53
|
+
void cancelPinnedImageResponse(active);
|
|
54
|
+
throw error;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
async function readBoundedImageBody(response, options) {
|
|
58
|
+
const onAbort = () => { void cancelPinnedImageResponse(response); };
|
|
59
|
+
options.signal.addEventListener("abort", onAbort, { once: true });
|
|
60
|
+
try {
|
|
61
|
+
options.signal.throwIfAborted();
|
|
62
|
+
if (Number(response.headers.get("content-length")) > options.maxBytes) {
|
|
63
|
+
throw new ImageBodyFailure("too-large");
|
|
64
|
+
}
|
|
65
|
+
if (!response.body)
|
|
66
|
+
throw new ImageBodyFailure("empty");
|
|
67
|
+
const chunks = [];
|
|
68
|
+
let total = 0;
|
|
69
|
+
for await (const chunk of response.body) {
|
|
70
|
+
options.signal.throwIfAborted();
|
|
71
|
+
total += chunk.byteLength;
|
|
72
|
+
if (total > options.maxBytes) {
|
|
73
|
+
void cancelPinnedImageResponse(response);
|
|
74
|
+
throw new ImageBodyFailure("too-large");
|
|
75
|
+
}
|
|
76
|
+
chunks.push(Buffer.from(chunk));
|
|
77
|
+
}
|
|
78
|
+
options.signal.throwIfAborted();
|
|
79
|
+
if (!total)
|
|
80
|
+
throw new ImageBodyFailure("empty");
|
|
81
|
+
return Buffer.concat(chunks, total);
|
|
82
|
+
}
|
|
83
|
+
catch (error) {
|
|
84
|
+
void cancelPinnedImageResponse(response);
|
|
85
|
+
throw error;
|
|
86
|
+
}
|
|
87
|
+
finally {
|
|
88
|
+
options.signal.removeEventListener("abort", onAbort);
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
async function downloadImageHops(url, policy, signal) {
|
|
92
|
+
const trustedOrigin = policy.trustedProxyOrigin ? new URL(policy.trustedProxyOrigin).origin : undefined;
|
|
93
|
+
let trusted = trustedOrigin !== undefined && url.origin === trustedOrigin;
|
|
94
|
+
let currentUrl = url;
|
|
95
|
+
for (let redirects = 0;; redirects++) {
|
|
96
|
+
signal.throwIfAborted();
|
|
97
|
+
const effectiveHopPolicy = trusted ? { trustedProxyOrigin: trustedOrigin } : {};
|
|
98
|
+
const response = await fetchPinnedImageWithRetry(currentUrl, effectiveHopPolicy, signal);
|
|
99
|
+
try {
|
|
100
|
+
signal.throwIfAborted();
|
|
101
|
+
if (IMAGE_REDIRECT_STATUSES.has(response.status)) {
|
|
102
|
+
const location = response.headers.get("location");
|
|
103
|
+
if (!location?.trim() || redirects >= MAX_IMAGE_REDIRECTS) {
|
|
104
|
+
throw new ImageDownloadFailure("Image download redirect refused");
|
|
105
|
+
}
|
|
106
|
+
currentUrl = new URL(location, currentUrl);
|
|
107
|
+
trusted = trusted && currentUrl.origin === trustedOrigin;
|
|
108
|
+
continue;
|
|
109
|
+
}
|
|
110
|
+
if (response.status < 200 || response.status >= 300) {
|
|
111
|
+
throw new ImageDownloadFailure(`Image download failed: HTTP ${response.status}`);
|
|
112
|
+
}
|
|
113
|
+
const buffer = await readBoundedImageBody(response, { maxBytes: MAX_IMAGE_DOWNLOAD_BYTES, signal });
|
|
114
|
+
signal.throwIfAborted();
|
|
115
|
+
const b64 = buffer.toString("base64");
|
|
116
|
+
const mime = response.headers.get("content-type")?.split(";")[0]?.trim()
|
|
117
|
+
|| detectImageMimeFromB64(b64) || "image/png";
|
|
118
|
+
return { buffer, b64, mime };
|
|
119
|
+
}
|
|
120
|
+
finally {
|
|
121
|
+
void cancelPinnedImageResponse(response);
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
export async function downloadGrokImageUrl(url, signal, timeoutMs = 30_000, policy = {}) {
|
|
126
|
+
const controller = new AbortController();
|
|
127
|
+
const combined = new AbortController();
|
|
128
|
+
const onAbort = () => combined.abort(signal?.reason);
|
|
129
|
+
const timer = setTimeout(() => { controller.abort(); combined.abort(controller.signal.reason); }, timeoutMs);
|
|
130
|
+
signal?.addEventListener("abort", onAbort, { once: true });
|
|
131
|
+
try {
|
|
132
|
+
if (signal?.aborted)
|
|
133
|
+
onAbort();
|
|
134
|
+
combined.signal.throwIfAborted();
|
|
135
|
+
const result = await downloadImageHops(new URL(url), policy, combined.signal);
|
|
136
|
+
combined.signal.throwIfAborted();
|
|
137
|
+
return result;
|
|
138
|
+
}
|
|
139
|
+
catch (error) {
|
|
140
|
+
if (signal?.aborted)
|
|
141
|
+
throw new ImageDownloadFailure("Generation canceled", 499, "GENERATION_CANCELED");
|
|
142
|
+
if (controller.signal.aborted)
|
|
143
|
+
throw new ImageDownloadFailure("Image download timed out", 504, "GROK_IMAGE_TIMEOUT");
|
|
144
|
+
if (error instanceof ImageBodyFailure) {
|
|
145
|
+
throw new ImageDownloadFailure(error.reason === "too-large"
|
|
146
|
+
? "Image download exceeds 50MB limit" : "Image download was empty");
|
|
147
|
+
}
|
|
148
|
+
if (error instanceof ImageDownloadFailure)
|
|
149
|
+
throw error;
|
|
150
|
+
if (error instanceof PinnedGetFailure)
|
|
151
|
+
throw new ImageDownloadFailure(error.message);
|
|
152
|
+
// Never publish raw DNS/socket errors, URL parse errors or signed URL credentials.
|
|
153
|
+
throw new ImageDownloadFailure("Image download failed");
|
|
154
|
+
}
|
|
155
|
+
finally {
|
|
156
|
+
clearTimeout(timer);
|
|
157
|
+
signal?.removeEventListener("abort", onAbort);
|
|
158
|
+
}
|
|
159
|
+
}
|