@vgai/fal 0.1.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/README.md +41 -0
- package/package.json +53 -0
- package/src/accept.tool.ts +49 -0
- package/src/accept.ts +193 -0
- package/src/cancel.tool.ts +36 -0
- package/src/cancel.ts +70 -0
- package/src/contracts.ts +102 -0
- package/src/poll.tool.ts +70 -0
- package/src/poll.ts +55 -0
- package/src/pricing.ts +149 -0
- package/src/provider.ts +88 -0
- package/src/quote.tool.ts +20 -0
- package/src/submit.tool.ts +101 -0
- package/src/submit.ts +27 -0
- package/src/tool-error.ts +29 -0
package/README.md
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
# @vgai/fal
|
|
2
|
+
|
|
3
|
+
Optional Project Tools and editor UI for Fal. The package registers
|
|
4
|
+
`project.fal.quote`, `project.fal.submit`, `project.fal.poll`, and
|
|
5
|
+
`project.fal.cancel`, and `project.fal.accept`. Quote reads current
|
|
6
|
+
account-specific pricing without inspecting provider wallet balance; submission
|
|
7
|
+
creates an external candidate; cancellation changes the shared job
|
|
8
|
+
only after Fal confirms it; acceptance validates and atomically commits its
|
|
9
|
+
ordinary files and provenance. Install `@vgai/fal-client-compat` alone when only
|
|
10
|
+
directly authored `@fal-ai/client` calls are needed.
|
|
11
|
+
|
|
12
|
+
Operation-shaped creation documents submit native Fal inputs for image
|
|
13
|
+
generation, reference-conditioned image editing, audio, object reconstruction,
|
|
14
|
+
video, panorama, and layered-world work. The image-edit document accepts one
|
|
15
|
+
reference URL per line and submits the ordered list as native `image_urls`.
|
|
16
|
+
Native JSON is available under Advanced. The poll operation contributes native
|
|
17
|
+
result media inside the editor-owned result document; all operations still feed
|
|
18
|
+
the single durable **Generations** activity ledger. There is no
|
|
19
|
+
provider-specific queue.
|
|
20
|
+
|
|
21
|
+
Mock is the default UI mode. Direct mode reads `FAL_KEY`; managed mode reads
|
|
22
|
+
`VGAI_GENERATION_GATEWAY` and `VGAI_ACCESS_TOKEN`. Supported registered
|
|
23
|
+
endpoints match the compatibility package's tested image, audio, splat, video,
|
|
24
|
+
panorama, and Hunyuan-world slices. Other Fal endpoints remain directly
|
|
25
|
+
callable and are not forced through a VGAI request schema.
|
|
26
|
+
The global Account document can store the Fal key in the operating system
|
|
27
|
+
credential manager; the trusted editor server hydrates `FAL_KEY` before tool
|
|
28
|
+
execution. An explicitly inherited `FAL_KEY` remains the higher-priority
|
|
29
|
+
CI/agent override.
|
|
30
|
+
Connection testing verifies the inference credential without inspecting the
|
|
31
|
+
provider wallet. Poll results preserve native status and queue position. Submit also preserves Fal's
|
|
32
|
+
provider-issued response, status, and cancellation URLs; status responses are
|
|
33
|
+
tolerated when the live provider omits those documented fields. Cancellation
|
|
34
|
+
uses the exact saved URL instead of reconstructing one from a possibly aliased
|
|
35
|
+
endpoint. The shared ledger separately records the last poll and the last
|
|
36
|
+
actual provider-state change.
|
|
37
|
+
|
|
38
|
+
The document receives only the editor's sanitized account projection; `FAL_KEY`
|
|
39
|
+
and the VGAI bearer token stay in Node transports. Its result module exports
|
|
40
|
+
`match(job, result)` so future Fal result shapes cannot collide by registration
|
|
41
|
+
order.
|
package/package.json
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@vgai/fal",
|
|
3
|
+
"author": "Volter AI, Inc.",
|
|
4
|
+
"license": "Apache-2.0",
|
|
5
|
+
"version": "0.1.0",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"description": "Optional registered Fal provider boundary for VGAI projects: quote, submit, poll and accept behind guarded pricing and credentials.",
|
|
8
|
+
"homepage": "https://github.com/volter-ai/vgai-engine#readme",
|
|
9
|
+
"repository": {
|
|
10
|
+
"type": "git",
|
|
11
|
+
"url": "git+https://github.com/volter-ai/vgai-engine.git",
|
|
12
|
+
"directory": "packages/fal"
|
|
13
|
+
},
|
|
14
|
+
"publishConfig": {
|
|
15
|
+
"access": "public"
|
|
16
|
+
},
|
|
17
|
+
"files": [
|
|
18
|
+
"src",
|
|
19
|
+
"README.md"
|
|
20
|
+
],
|
|
21
|
+
"exports": {
|
|
22
|
+
"./accept": "./src/accept.ts",
|
|
23
|
+
"./contracts": "./src/contracts.ts",
|
|
24
|
+
"./package.json": "./package.json",
|
|
25
|
+
"./poll": "./src/poll.ts",
|
|
26
|
+
"./pricing": "./src/pricing.ts",
|
|
27
|
+
"./submit": "./src/submit.ts"
|
|
28
|
+
},
|
|
29
|
+
"vgai": {
|
|
30
|
+
"tools": [
|
|
31
|
+
"./src/submit.tool.ts",
|
|
32
|
+
"./src/quote.tool.ts",
|
|
33
|
+
"./src/cancel.tool.ts",
|
|
34
|
+
"./src/poll.tool.ts",
|
|
35
|
+
"./src/accept.tool.ts"
|
|
36
|
+
]
|
|
37
|
+
},
|
|
38
|
+
"dependencies": {
|
|
39
|
+
"@fal-ai/client": "1.10.1",
|
|
40
|
+
"@vgai/fal-client-compat": "0.1.0",
|
|
41
|
+
"@vgai/sdk": "0.5.0",
|
|
42
|
+
"fflate": "^0.8.2",
|
|
43
|
+
"three": "^0.180.0",
|
|
44
|
+
"zod": "^4.3.6"
|
|
45
|
+
},
|
|
46
|
+
"devDependencies": {
|
|
47
|
+
"@types/node": "^25.3.0",
|
|
48
|
+
"@types/react": "^19.2.14",
|
|
49
|
+
"@types/three": "^0.180.0",
|
|
50
|
+
"@vgai/editor-sdk": "0.5.0",
|
|
51
|
+
"react": "^19.2.4"
|
|
52
|
+
}
|
|
53
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import type { GenerationToolContribution } from '@vgai/sdk/generations';
|
|
2
|
+
import { defineTool } from '@vgai/sdk/tools';
|
|
3
|
+
import { acceptFal } from './accept.js';
|
|
4
|
+
import { AcceptInputSchema, AcceptResultSchema } from './contracts.js';
|
|
5
|
+
import { falTool, falToolErrors } from './tool-error.js';
|
|
6
|
+
export const tool = defineTool({
|
|
7
|
+
name: 'project.fal.accept',
|
|
8
|
+
summary: 'Accept a completed Fal request',
|
|
9
|
+
description:
|
|
10
|
+
'Downloads and validates a completed Fal result, then atomically commits ordinary files and provenance.',
|
|
11
|
+
input: AcceptInputSchema,
|
|
12
|
+
result: AcceptResultSchema,
|
|
13
|
+
errors: falToolErrors,
|
|
14
|
+
requires: { project: true },
|
|
15
|
+
host: 'node',
|
|
16
|
+
mutates: true,
|
|
17
|
+
supportsDryRun: false,
|
|
18
|
+
longRunning: true,
|
|
19
|
+
permission: { risk: 'write', summary: 'Writes one accepted Fal output batch under public/.' },
|
|
20
|
+
impl: falTool(acceptFal),
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
export const generation = {
|
|
24
|
+
role: 'accept',
|
|
25
|
+
provider: 'fal',
|
|
26
|
+
toUpdate(inputValue, resultValue) {
|
|
27
|
+
const input = AcceptInputSchema.parse(inputValue);
|
|
28
|
+
const result = AcceptResultSchema.parse(resultValue);
|
|
29
|
+
return {
|
|
30
|
+
provider: 'fal',
|
|
31
|
+
externalId: input.requestId,
|
|
32
|
+
...(input.mode === 'direct' && result.settledAmount !== undefined
|
|
33
|
+
? {
|
|
34
|
+
billing: {
|
|
35
|
+
route: 'byok' as const,
|
|
36
|
+
currency: 'USD' as const,
|
|
37
|
+
settledAmount: result.settledAmount,
|
|
38
|
+
},
|
|
39
|
+
}
|
|
40
|
+
: input.mode === 'managed' && result.settledCredits !== undefined
|
|
41
|
+
? { billing: { route: 'managed' as const, settledCredits: result.settledCredits } }
|
|
42
|
+
: {}),
|
|
43
|
+
accepted: {
|
|
44
|
+
provenanceOperationId: result.provenanceOperationId,
|
|
45
|
+
outputPaths: result.files.map((file) => file.path),
|
|
46
|
+
},
|
|
47
|
+
};
|
|
48
|
+
},
|
|
49
|
+
} satisfies GenerationToolContribution;
|
package/src/accept.ts
ADDED
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
import { posix } from 'node:path';
|
|
2
|
+
import type { ToolContext } from '@vgai/sdk/tools';
|
|
3
|
+
import { unzipSync } from 'fflate';
|
|
4
|
+
import { PLYLoader } from 'three/addons/loaders/PLYLoader.js';
|
|
5
|
+
import type { AcceptInput, FalEndpoint } from './contracts.js';
|
|
6
|
+
import { SEEDANCE_2_REFERENCE_TO_VIDEO_ENDPOINT } from './contracts.js';
|
|
7
|
+
import { quoteFalPrice } from './pricing.js';
|
|
8
|
+
import { falArtifactFetch, falBilling, falClient } from './provider.js';
|
|
9
|
+
|
|
10
|
+
interface NativeFile {
|
|
11
|
+
url: string;
|
|
12
|
+
content_type?: string;
|
|
13
|
+
file_name?: string;
|
|
14
|
+
}
|
|
15
|
+
interface AcceptedFile {
|
|
16
|
+
filename: string;
|
|
17
|
+
mediaType: string;
|
|
18
|
+
bytes: Uint8Array;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function directory(value: string): string {
|
|
22
|
+
const normalized = posix
|
|
23
|
+
.normalize(value.replaceAll('\\', '/'))
|
|
24
|
+
.replace(/^\.\//, '')
|
|
25
|
+
.replace(/\/$/, '');
|
|
26
|
+
if (
|
|
27
|
+
!normalized.startsWith('public/') ||
|
|
28
|
+
normalized === 'public' ||
|
|
29
|
+
normalized.split('/').includes('..')
|
|
30
|
+
) {
|
|
31
|
+
throw new Error('Fal outputDirectory must be a project-relative directory under public/.');
|
|
32
|
+
}
|
|
33
|
+
return normalized;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function nativeFile(value: unknown, label: string): NativeFile {
|
|
37
|
+
if (!value || typeof value !== 'object' || typeof (value as NativeFile).url !== 'string') {
|
|
38
|
+
throw new Error(`Fal result has no ${label} file.`);
|
|
39
|
+
}
|
|
40
|
+
return value as NativeFile;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function decodeDataUrl(url: string): Uint8Array | undefined {
|
|
44
|
+
const match = /^data:[^;,]+;base64,(.+)$/.exec(url);
|
|
45
|
+
return match?.[1] ? Uint8Array.from(Buffer.from(match[1], 'base64')) : undefined;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
async function download(file: NativeFile, fetcher: typeof fetch): Promise<Uint8Array> {
|
|
49
|
+
const embedded = decodeDataUrl(file.url);
|
|
50
|
+
if (embedded) return embedded;
|
|
51
|
+
const response = await fetcher(file.url);
|
|
52
|
+
if (!response.ok) throw new Error(`Fal artifact download failed with HTTP ${response.status}.`);
|
|
53
|
+
return new Uint8Array(await response.arrayBuffer());
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function png(bytes: Uint8Array, panorama = false): void {
|
|
57
|
+
const signature = [137, 80, 78, 71, 13, 10, 26, 10];
|
|
58
|
+
if (bytes.byteLength < 24 || !signature.every((byte, index) => bytes[index] === byte))
|
|
59
|
+
throw new Error('Fal image is not a PNG container.');
|
|
60
|
+
if (panorama) {
|
|
61
|
+
const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
|
|
62
|
+
if (view.getUint32(16) !== view.getUint32(20) * 2)
|
|
63
|
+
throw new Error('Fal panorama must be exactly 2:1.');
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function wav(bytes: Uint8Array): void {
|
|
68
|
+
const text = new TextDecoder().decode(bytes.subarray(0, 12));
|
|
69
|
+
if (!text.startsWith('RIFF') || !text.endsWith('WAVE'))
|
|
70
|
+
throw new Error('Fal audio is not a WAV container.');
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function webm(bytes: Uint8Array): void {
|
|
74
|
+
if (![0x1a, 0x45, 0xdf, 0xa3].every((byte, index) => bytes[index] === byte))
|
|
75
|
+
throw new Error('Fal video is not a WebM container.');
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function mp4(bytes: Uint8Array): void {
|
|
79
|
+
if (bytes.byteLength < 12 || new TextDecoder().decode(bytes.subarray(4, 8)) !== 'ftyp') {
|
|
80
|
+
throw new Error('Fal video is not an MP4 container.');
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
async function video(file: NativeFile, fetcher: typeof fetch): Promise<AcceptedFile> {
|
|
85
|
+
const bytes = await download(file, fetcher);
|
|
86
|
+
const mediaType = file.content_type?.split(';', 1)[0]?.toLowerCase();
|
|
87
|
+
if (mediaType === 'video/webm' || bytes[0] === 0x1a) {
|
|
88
|
+
webm(bytes);
|
|
89
|
+
return { filename: 'video.webm', mediaType: 'video/webm', bytes };
|
|
90
|
+
}
|
|
91
|
+
mp4(bytes);
|
|
92
|
+
return { filename: 'video.mp4', mediaType: 'video/mp4', bytes };
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function ply(bytes: Uint8Array): void {
|
|
96
|
+
const geometry = new PLYLoader().parse(Uint8Array.from(bytes).buffer);
|
|
97
|
+
try {
|
|
98
|
+
if (!geometry.getAttribute('position') || geometry.getAttribute('position').count === 0)
|
|
99
|
+
throw new Error('Fal PLY contains no positions.');
|
|
100
|
+
} finally {
|
|
101
|
+
geometry.dispose();
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
async function acceptedFiles(
|
|
106
|
+
endpoint: FalEndpoint,
|
|
107
|
+
data: Record<string, unknown>,
|
|
108
|
+
fetcher: typeof fetch,
|
|
109
|
+
): Promise<AcceptedFile[]> {
|
|
110
|
+
if (
|
|
111
|
+
endpoint === 'fal-ai/nano-banana-2' ||
|
|
112
|
+
endpoint === 'fal-ai/nano-banana-2/edit' ||
|
|
113
|
+
endpoint === 'fal-ai/nano-banana' ||
|
|
114
|
+
endpoint === 'fal-ai/nano-banana/edit'
|
|
115
|
+
) {
|
|
116
|
+
const images = data['images'];
|
|
117
|
+
if (!Array.isArray(images) || images.length === 0) throw new Error('Fal result has no images.');
|
|
118
|
+
return Promise.all(
|
|
119
|
+
images.map(async (value, index) => {
|
|
120
|
+
const bytes = await download(nativeFile(value, 'image'), fetcher);
|
|
121
|
+
png(bytes);
|
|
122
|
+
return { filename: `image-${index + 1}.png`, mediaType: 'image/png', bytes };
|
|
123
|
+
}),
|
|
124
|
+
);
|
|
125
|
+
}
|
|
126
|
+
if (endpoint === 'fal-ai/stable-audio') {
|
|
127
|
+
const bytes = await download(nativeFile(data['audio_file'], 'audio'), fetcher);
|
|
128
|
+
wav(bytes);
|
|
129
|
+
return [{ filename: 'audio.wav', mediaType: 'audio/wav', bytes }];
|
|
130
|
+
}
|
|
131
|
+
if (endpoint === 'fal-ai/sam-3/3d-objects') {
|
|
132
|
+
const bytes = await download(nativeFile(data['gaussian_splat'], 'Gaussian splat'), fetcher);
|
|
133
|
+
ply(bytes);
|
|
134
|
+
return [{ filename: 'splat.ply', mediaType: 'application/octet-stream', bytes }];
|
|
135
|
+
}
|
|
136
|
+
if (
|
|
137
|
+
endpoint === 'fal-ai/minimax/hailuo-02/standard/text-to-video' ||
|
|
138
|
+
endpoint === SEEDANCE_2_REFERENCE_TO_VIDEO_ENDPOINT
|
|
139
|
+
) {
|
|
140
|
+
return [await video(nativeFile(data['video'], 'video'), fetcher)];
|
|
141
|
+
}
|
|
142
|
+
if (endpoint === 'fal-ai/hunyuan_world') {
|
|
143
|
+
const bytes = await download(nativeFile(data['image'], 'panorama'), fetcher);
|
|
144
|
+
png(bytes, true);
|
|
145
|
+
return [{ filename: 'panorama.png', mediaType: 'image/png', bytes }];
|
|
146
|
+
}
|
|
147
|
+
const bytes = await download(nativeFile(data['world_file'], 'world archive'), fetcher);
|
|
148
|
+
const archive = unzipSync(bytes);
|
|
149
|
+
for (const name of ['mesh_layer0.ply', 'mesh_layer1.ply']) {
|
|
150
|
+
const layer = archive[name];
|
|
151
|
+
if (!layer) throw new Error(`Fal world archive is missing ${name}.`);
|
|
152
|
+
ply(layer);
|
|
153
|
+
}
|
|
154
|
+
return [{ filename: 'world.zip', mediaType: 'application/zip', bytes }];
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
export async function acceptFal(input: AcceptInput, ctx: ToolContext) {
|
|
158
|
+
if (!ctx.projectOutputs) throw new Error('The project-output writer is unavailable.');
|
|
159
|
+
const result = await falClient(input.mode).queue.result(input.endpoint, {
|
|
160
|
+
requestId: input.requestId,
|
|
161
|
+
});
|
|
162
|
+
const usage = falBilling(input.requestId);
|
|
163
|
+
const quote = input.mode === 'direct' ? await quoteFalPrice(input) : undefined;
|
|
164
|
+
const files = await acceptedFiles(
|
|
165
|
+
input.endpoint,
|
|
166
|
+
result.data as Record<string, unknown>,
|
|
167
|
+
falArtifactFetch(input.mode),
|
|
168
|
+
);
|
|
169
|
+
const root = directory(input.outputDirectory);
|
|
170
|
+
const committed = await ctx.projectOutputs.write(
|
|
171
|
+
files.map((file) => ({
|
|
172
|
+
path: `${root}/${file.filename}`,
|
|
173
|
+
content: file.bytes,
|
|
174
|
+
mediaType: file.mediaType,
|
|
175
|
+
role: 'asset' as const,
|
|
176
|
+
})),
|
|
177
|
+
);
|
|
178
|
+
if (!committed.provenanceOperationId)
|
|
179
|
+
throw new Error('Fal output commit returned no provenance operation ID.');
|
|
180
|
+
return {
|
|
181
|
+
requestId: input.requestId,
|
|
182
|
+
files: committed.files.map((file) => ({
|
|
183
|
+
path: file.path,
|
|
184
|
+
bytes: file.bytes,
|
|
185
|
+
...(file.mediaType ? { mediaType: file.mediaType } : {}),
|
|
186
|
+
})),
|
|
187
|
+
provenanceOperationId: committed.provenanceOperationId,
|
|
188
|
+
...(quote && usage?.billableUnits !== undefined
|
|
189
|
+
? { settledAmount: quote.unitPrice * usage.billableUnits }
|
|
190
|
+
: {}),
|
|
191
|
+
...(usage?.settledCredits === undefined ? {} : { settledCredits: usage.settledCredits }),
|
|
192
|
+
};
|
|
193
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import type { GenerationToolContribution } from '@vgai/sdk/generations';
|
|
2
|
+
import { defineTool } from '@vgai/sdk/tools';
|
|
3
|
+
import { cancelFal } from './cancel.js';
|
|
4
|
+
import { CancelInputSchema, CancelResultSchema } from './contracts.js';
|
|
5
|
+
import { falTool, falToolErrors } from './tool-error.js';
|
|
6
|
+
|
|
7
|
+
export const tool = defineTool({
|
|
8
|
+
name: 'project.fal.cancel',
|
|
9
|
+
summary: 'Cancel a Fal request',
|
|
10
|
+
description: 'Cancels one queued or running Fal request without writing generated project files.',
|
|
11
|
+
input: CancelInputSchema,
|
|
12
|
+
result: CancelResultSchema,
|
|
13
|
+
errors: falToolErrors,
|
|
14
|
+
requires: { project: true },
|
|
15
|
+
host: 'node',
|
|
16
|
+
mutates: false,
|
|
17
|
+
supportsDryRun: false,
|
|
18
|
+
longRunning: false,
|
|
19
|
+
permission: { risk: 'write', summary: 'Cancels one external Fal request.' },
|
|
20
|
+
impl: falTool(cancelFal),
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
export const generation = {
|
|
24
|
+
role: 'cancel',
|
|
25
|
+
provider: 'fal',
|
|
26
|
+
toUpdate(inputValue, resultValue) {
|
|
27
|
+
const input = CancelInputSchema.parse(inputValue);
|
|
28
|
+
CancelResultSchema.parse(resultValue);
|
|
29
|
+
return {
|
|
30
|
+
provider: 'fal',
|
|
31
|
+
externalId: input.requestId,
|
|
32
|
+
status: 'cancelled' as const,
|
|
33
|
+
message: 'Cancelled at the provider.',
|
|
34
|
+
};
|
|
35
|
+
},
|
|
36
|
+
} satisfies GenerationToolContribution;
|
package/src/cancel.ts
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import { ApiError } from '@fal-ai/client';
|
|
2
|
+
import { ToolError } from '@vgai/sdk/tools';
|
|
3
|
+
import type { CancelInput } from './contracts.js';
|
|
4
|
+
import { falClient } from './provider.js';
|
|
5
|
+
|
|
6
|
+
function endpointFromCancelUrl(cancelUrl: string, requestId: string): string {
|
|
7
|
+
const url = new URL(cancelUrl);
|
|
8
|
+
const match = /^\/(.+)\/requests\/([^/]+)\/cancel$/.exec(url.pathname);
|
|
9
|
+
if (
|
|
10
|
+
url.protocol !== 'https:' ||
|
|
11
|
+
url.hostname !== 'queue.fal.run' ||
|
|
12
|
+
!match?.[1] ||
|
|
13
|
+
decodeURIComponent(match[2] ?? '') !== requestId
|
|
14
|
+
) {
|
|
15
|
+
throw new ToolError('PROVIDER_ERROR', 'Fal returned an invalid cancellation URL.');
|
|
16
|
+
}
|
|
17
|
+
return match[1];
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export async function cancelFal(input: CancelInput) {
|
|
21
|
+
try {
|
|
22
|
+
const endpoint = input.cancelUrl
|
|
23
|
+
? endpointFromCancelUrl(input.cancelUrl, input.requestId)
|
|
24
|
+
: input.endpoint;
|
|
25
|
+
await falClient(input.mode).queue.cancel(endpoint, {
|
|
26
|
+
requestId: input.requestId,
|
|
27
|
+
});
|
|
28
|
+
} catch (cause) {
|
|
29
|
+
const body =
|
|
30
|
+
cause instanceof ApiError ? (cause.body as Record<string, unknown> | undefined) : undefined;
|
|
31
|
+
const providerStatus = typeof body?.['status'] === 'string' ? body['status'] : undefined;
|
|
32
|
+
if (cause instanceof ApiError && cause.status === 404) {
|
|
33
|
+
throw new ToolError(
|
|
34
|
+
'REQUEST_NOT_FOUND',
|
|
35
|
+
`Fal does not recognize request ${input.requestId}; cancellation was not recorded.`,
|
|
36
|
+
{
|
|
37
|
+
provider: 'fal',
|
|
38
|
+
requestId: input.requestId,
|
|
39
|
+
status: cause.status,
|
|
40
|
+
providerStatus,
|
|
41
|
+
...(input.cancelUrl ? { cancelUrl: input.cancelUrl } : {}),
|
|
42
|
+
},
|
|
43
|
+
);
|
|
44
|
+
}
|
|
45
|
+
if (
|
|
46
|
+
cause instanceof ApiError &&
|
|
47
|
+
cause.status === 400 &&
|
|
48
|
+
providerStatus === 'ALREADY_COMPLETED'
|
|
49
|
+
) {
|
|
50
|
+
throw new ToolError(
|
|
51
|
+
'ALREADY_COMPLETED',
|
|
52
|
+
`Fal completed request ${input.requestId} before cancellation. Poll it for the result.`,
|
|
53
|
+
{ provider: 'fal', requestId: input.requestId, status: cause.status, providerStatus },
|
|
54
|
+
);
|
|
55
|
+
}
|
|
56
|
+
const detail = body?.['detail'] ?? body?.['message'];
|
|
57
|
+
const message =
|
|
58
|
+
typeof detail === 'string'
|
|
59
|
+
? detail
|
|
60
|
+
: detail !== undefined
|
|
61
|
+
? JSON.stringify(detail)
|
|
62
|
+
: cause instanceof Error
|
|
63
|
+
? cause.message
|
|
64
|
+
: String(cause);
|
|
65
|
+
throw new ToolError('PROVIDER_ERROR', message, {
|
|
66
|
+
...(cause instanceof ApiError && cause.status ? { status: cause.status } : {}),
|
|
67
|
+
});
|
|
68
|
+
}
|
|
69
|
+
return { requestId: input.requestId, status: 'cancelled' as const };
|
|
70
|
+
}
|
package/src/contracts.ts
ADDED
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
|
|
3
|
+
export const ExecutionModeSchema = z.enum(['mock', 'direct', 'managed']);
|
|
4
|
+
export const DEFAULT_FAL_IMAGE_ENDPOINT = 'fal-ai/nano-banana-2' as const;
|
|
5
|
+
export const DEFAULT_FAL_IMAGE_EDIT_ENDPOINT = 'fal-ai/nano-banana-2/edit' as const;
|
|
6
|
+
export const NANO_BANANA_2_UNDERLYING_ENDPOINT = 'fal-ai/gemini-3.1-flash-image-preview' as const;
|
|
7
|
+
export const SEEDANCE_2_REFERENCE_TO_VIDEO_ENDPOINT =
|
|
8
|
+
'bytedance/seedance-2.0/reference-to-video' as const;
|
|
9
|
+
export const FAL_IMAGE_CONTRIBUTION_DEFAULTS = {
|
|
10
|
+
'generate-image': DEFAULT_FAL_IMAGE_ENDPOINT,
|
|
11
|
+
'edit-image': DEFAULT_FAL_IMAGE_EDIT_ENDPOINT,
|
|
12
|
+
} as const;
|
|
13
|
+
export const FalEndpointSchema = z.enum([
|
|
14
|
+
DEFAULT_FAL_IMAGE_ENDPOINT,
|
|
15
|
+
DEFAULT_FAL_IMAGE_EDIT_ENDPOINT,
|
|
16
|
+
NANO_BANANA_2_UNDERLYING_ENDPOINT,
|
|
17
|
+
'fal-ai/nano-banana',
|
|
18
|
+
'fal-ai/nano-banana/edit',
|
|
19
|
+
'fal-ai/stable-audio',
|
|
20
|
+
'fal-ai/sam-3/3d-objects',
|
|
21
|
+
'fal-ai/minimax/hailuo-02/standard/text-to-video',
|
|
22
|
+
SEEDANCE_2_REFERENCE_TO_VIDEO_ENDPOINT,
|
|
23
|
+
'fal-ai/hunyuan_world/image-to-world',
|
|
24
|
+
'fal-ai/hunyuan_world',
|
|
25
|
+
]);
|
|
26
|
+
export const SubmissionSchema = z.object({
|
|
27
|
+
mode: ExecutionModeSchema.default('mock'),
|
|
28
|
+
endpoint: FalEndpointSchema,
|
|
29
|
+
input: z.record(z.string(), z.unknown()),
|
|
30
|
+
outputDirectory: z.string().min(1).default('public/generated/fal/output'),
|
|
31
|
+
});
|
|
32
|
+
export const QuoteInputSchema = SubmissionSchema.pick({ mode: true, endpoint: true, input: true });
|
|
33
|
+
export const QuoteResultSchema = z
|
|
34
|
+
.object({
|
|
35
|
+
mode: ExecutionModeSchema,
|
|
36
|
+
endpoint: FalEndpointSchema,
|
|
37
|
+
currency: z.literal('USD'),
|
|
38
|
+
unitPrice: z.number().nonnegative(),
|
|
39
|
+
unit: z.string().min(1),
|
|
40
|
+
quantity: z.number().positive().optional(),
|
|
41
|
+
estimatedAmount: z.number().nonnegative().optional(),
|
|
42
|
+
estimatedCredits: z.number().nonnegative().optional(),
|
|
43
|
+
})
|
|
44
|
+
.nullable();
|
|
45
|
+
export const SubmitResultSchema = z.object({
|
|
46
|
+
requestId: z.string(),
|
|
47
|
+
responseUrl: z.string().url(),
|
|
48
|
+
statusUrl: z.string().url(),
|
|
49
|
+
cancelUrl: z.string().url(),
|
|
50
|
+
mode: ExecutionModeSchema,
|
|
51
|
+
endpoint: FalEndpointSchema,
|
|
52
|
+
input: z.record(z.string(), z.unknown()),
|
|
53
|
+
outputDirectory: z.string(),
|
|
54
|
+
quote: QuoteResultSchema.optional(),
|
|
55
|
+
estimatedCredits: z.number().nonnegative().optional(),
|
|
56
|
+
});
|
|
57
|
+
export const PollInputSchema = z.object({
|
|
58
|
+
mode: ExecutionModeSchema,
|
|
59
|
+
endpoint: FalEndpointSchema,
|
|
60
|
+
requestId: z.string().min(1),
|
|
61
|
+
});
|
|
62
|
+
export const PollResultSchema = z.object({
|
|
63
|
+
requestId: z.string(),
|
|
64
|
+
responseUrl: z.string().url().optional(),
|
|
65
|
+
statusUrl: z.string().url().optional(),
|
|
66
|
+
cancelUrl: z.string().url().optional(),
|
|
67
|
+
status: z.enum(['IN_QUEUE', 'IN_PROGRESS', 'COMPLETED']),
|
|
68
|
+
done: z.boolean(),
|
|
69
|
+
queuePosition: z.number().int().nonnegative().optional(),
|
|
70
|
+
estimatedCredits: z.number().nonnegative().optional(),
|
|
71
|
+
settledCredits: z.number().nonnegative().optional(),
|
|
72
|
+
output: z.unknown().optional(),
|
|
73
|
+
error: z.object({ status: z.number().int().optional(), message: z.string().min(1) }).optional(),
|
|
74
|
+
});
|
|
75
|
+
export const CancelInputSchema = PollInputSchema.extend({
|
|
76
|
+
cancelUrl: z.string().url().optional(),
|
|
77
|
+
});
|
|
78
|
+
export const CancelResultSchema = z.object({
|
|
79
|
+
requestId: z.string(),
|
|
80
|
+
status: z.literal('cancelled'),
|
|
81
|
+
});
|
|
82
|
+
export const AcceptInputSchema = SubmissionSchema.extend({ requestId: z.string().min(1) });
|
|
83
|
+
export const AcceptResultSchema = z.object({
|
|
84
|
+
requestId: z.string(),
|
|
85
|
+
files: z.array(
|
|
86
|
+
z.object({
|
|
87
|
+
path: z.string(),
|
|
88
|
+
bytes: z.number().int().nonnegative(),
|
|
89
|
+
mediaType: z.string().optional(),
|
|
90
|
+
}),
|
|
91
|
+
),
|
|
92
|
+
provenanceOperationId: z.string(),
|
|
93
|
+
settledAmount: z.number().nonnegative().optional(),
|
|
94
|
+
settledCredits: z.number().nonnegative().optional(),
|
|
95
|
+
});
|
|
96
|
+
export type ExecutionMode = z.infer<typeof ExecutionModeSchema>;
|
|
97
|
+
export type FalEndpoint = z.infer<typeof FalEndpointSchema>;
|
|
98
|
+
export type Submission = z.infer<typeof SubmissionSchema>;
|
|
99
|
+
export type QuoteInput = z.infer<typeof QuoteInputSchema>;
|
|
100
|
+
export type PollInput = z.infer<typeof PollInputSchema>;
|
|
101
|
+
export type CancelInput = z.infer<typeof CancelInputSchema>;
|
|
102
|
+
export type AcceptInput = z.infer<typeof AcceptInputSchema>;
|
package/src/poll.tool.ts
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import type { GenerationToolContribution } from '@vgai/sdk/generations';
|
|
2
|
+
import { defineTool } from '@vgai/sdk/tools';
|
|
3
|
+
import { PollInputSchema, PollResultSchema } from './contracts.js';
|
|
4
|
+
import { pollFal } from './poll.js';
|
|
5
|
+
import { falTool, falToolErrors } from './tool-error.js';
|
|
6
|
+
export const tool = defineTool({
|
|
7
|
+
name: 'project.fal.poll',
|
|
8
|
+
summary: 'Inspect a Fal request',
|
|
9
|
+
description: 'Reads one native Fal queue request without writing project files.',
|
|
10
|
+
input: PollInputSchema,
|
|
11
|
+
result: PollResultSchema,
|
|
12
|
+
errors: falToolErrors,
|
|
13
|
+
requires: { project: true },
|
|
14
|
+
host: 'node',
|
|
15
|
+
mutates: false,
|
|
16
|
+
supportsDryRun: false,
|
|
17
|
+
longRunning: false,
|
|
18
|
+
permission: { risk: 'read', summary: 'Reads one Fal request.' },
|
|
19
|
+
impl: falTool(pollFal),
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
export const generation = {
|
|
23
|
+
role: 'poll',
|
|
24
|
+
provider: 'fal',
|
|
25
|
+
toUpdate(inputValue, resultValue) {
|
|
26
|
+
const input = PollInputSchema.parse(inputValue);
|
|
27
|
+
const result = PollResultSchema.parse(resultValue);
|
|
28
|
+
return {
|
|
29
|
+
provider: 'fal',
|
|
30
|
+
externalId: input.requestId,
|
|
31
|
+
status: result.error
|
|
32
|
+
? 'failed'
|
|
33
|
+
: result.status === 'COMPLETED'
|
|
34
|
+
? 'succeeded'
|
|
35
|
+
: result.status === 'IN_PROGRESS'
|
|
36
|
+
? 'running'
|
|
37
|
+
: 'queued',
|
|
38
|
+
providerStatus: result.status,
|
|
39
|
+
...(result.queuePosition === undefined ? {} : { queuePosition: result.queuePosition }),
|
|
40
|
+
...(result.error ? { message: result.error.message } : {}),
|
|
41
|
+
...(result.cancelUrl
|
|
42
|
+
? {
|
|
43
|
+
cancel: {
|
|
44
|
+
tool: 'project.fal.cancel',
|
|
45
|
+
input: {
|
|
46
|
+
mode: input.mode,
|
|
47
|
+
endpoint: input.endpoint,
|
|
48
|
+
requestId: input.requestId,
|
|
49
|
+
cancelUrl: result.cancelUrl,
|
|
50
|
+
},
|
|
51
|
+
},
|
|
52
|
+
}
|
|
53
|
+
: {}),
|
|
54
|
+
...(input.mode === 'managed' &&
|
|
55
|
+
(result.estimatedCredits !== undefined || result.settledCredits !== undefined)
|
|
56
|
+
? {
|
|
57
|
+
billing: {
|
|
58
|
+
route: 'managed' as const,
|
|
59
|
+
...(result.estimatedCredits === undefined
|
|
60
|
+
? {}
|
|
61
|
+
: { estimatedCredits: result.estimatedCredits }),
|
|
62
|
+
...(result.settledCredits === undefined
|
|
63
|
+
? {}
|
|
64
|
+
: { settledCredits: result.settledCredits }),
|
|
65
|
+
},
|
|
66
|
+
}
|
|
67
|
+
: {}),
|
|
68
|
+
};
|
|
69
|
+
},
|
|
70
|
+
} satisfies GenerationToolContribution;
|
package/src/poll.ts
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { ApiError } from '@fal-ai/client';
|
|
2
|
+
import type { PollInput } from './contracts.js';
|
|
3
|
+
import { falBilling, falClient } from './provider.js';
|
|
4
|
+
|
|
5
|
+
function providerError(error: unknown): { status?: number; message: string } {
|
|
6
|
+
if (error instanceof ApiError) {
|
|
7
|
+
const body = error.body as Record<string, unknown> | undefined;
|
|
8
|
+
const detail = body?.['detail'];
|
|
9
|
+
const message =
|
|
10
|
+
typeof detail === 'string'
|
|
11
|
+
? detail
|
|
12
|
+
: detail !== undefined
|
|
13
|
+
? JSON.stringify(detail)
|
|
14
|
+
: typeof body?.['message'] === 'string'
|
|
15
|
+
? body['message']
|
|
16
|
+
: body
|
|
17
|
+
? JSON.stringify(body)
|
|
18
|
+
: error.message;
|
|
19
|
+
return { status: error.status, message };
|
|
20
|
+
}
|
|
21
|
+
return { message: error instanceof Error ? error.message : String(error) };
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export async function pollFal(input: PollInput) {
|
|
25
|
+
const status = await falClient(input.mode).queue.status(input.endpoint, {
|
|
26
|
+
requestId: input.requestId,
|
|
27
|
+
});
|
|
28
|
+
let output: { data: unknown } | undefined;
|
|
29
|
+
let error: { status?: number; message: string } | undefined;
|
|
30
|
+
if (status.status === 'COMPLETED') {
|
|
31
|
+
try {
|
|
32
|
+
output = await falClient(input.mode).queue.result(input.endpoint, {
|
|
33
|
+
requestId: input.requestId,
|
|
34
|
+
});
|
|
35
|
+
} catch (cause) {
|
|
36
|
+
error = providerError(cause);
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
const billing = input.mode === 'managed' ? falBilling(input.requestId) : undefined;
|
|
40
|
+
return {
|
|
41
|
+
requestId: input.requestId,
|
|
42
|
+
...(status.response_url ? { responseUrl: status.response_url } : {}),
|
|
43
|
+
...(status.status_url ? { statusUrl: status.status_url } : {}),
|
|
44
|
+
...(status.cancel_url ? { cancelUrl: status.cancel_url } : {}),
|
|
45
|
+
status: status.status,
|
|
46
|
+
done: status.status === 'COMPLETED',
|
|
47
|
+
...(status.status === 'IN_QUEUE' ? { queuePosition: status.queue_position } : {}),
|
|
48
|
+
...(output ? { output: output.data } : {}),
|
|
49
|
+
...(billing?.estimatedCredits === undefined
|
|
50
|
+
? {}
|
|
51
|
+
: { estimatedCredits: billing.estimatedCredits }),
|
|
52
|
+
...(billing?.settledCredits === undefined ? {} : { settledCredits: billing.settledCredits }),
|
|
53
|
+
...(error ? { error } : {}),
|
|
54
|
+
};
|
|
55
|
+
}
|
package/src/pricing.ts
ADDED
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
import {
|
|
2
|
+
type ExecutionMode,
|
|
3
|
+
type FalEndpoint,
|
|
4
|
+
NANO_BANANA_2_UNDERLYING_ENDPOINT,
|
|
5
|
+
} from './contracts.js';
|
|
6
|
+
|
|
7
|
+
export interface FalPriceQuote {
|
|
8
|
+
mode: ExecutionMode;
|
|
9
|
+
endpoint: FalEndpoint;
|
|
10
|
+
currency: 'USD';
|
|
11
|
+
unitPrice: number;
|
|
12
|
+
unit: string;
|
|
13
|
+
quantity?: number;
|
|
14
|
+
estimatedAmount?: number;
|
|
15
|
+
estimatedCredits?: number;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function required(name: string): string {
|
|
19
|
+
const value = process.env[name];
|
|
20
|
+
if (!value) throw new Error(`${name} is required for this execution mode.`);
|
|
21
|
+
return value;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function positiveNumber(value: unknown): number | undefined {
|
|
25
|
+
const parsed =
|
|
26
|
+
typeof value === 'number' ? value : typeof value === 'string' ? Number(value) : NaN;
|
|
27
|
+
return Number.isFinite(parsed) && parsed > 0 ? parsed : undefined;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function duration(input: Record<string, unknown>): number | undefined {
|
|
31
|
+
return ['seconds_total', 'duration', 'video_length']
|
|
32
|
+
.map((key) => positiveNumber(input[key]))
|
|
33
|
+
.find((value) => value !== undefined);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function quantity(unit: string, input: Record<string, unknown>): number | undefined {
|
|
37
|
+
const normalized = unit.toLowerCase().replaceAll('-', '_').replaceAll(' ', '_');
|
|
38
|
+
if (normalized.includes('image') && !normalized.includes('megapixel')) {
|
|
39
|
+
return typeof input['num_images'] === 'number' ? input['num_images'] : 1;
|
|
40
|
+
}
|
|
41
|
+
if (normalized.includes('second')) {
|
|
42
|
+
return duration(input);
|
|
43
|
+
}
|
|
44
|
+
if (normalized.includes('request') || normalized.includes('generation') || normalized === 'unit')
|
|
45
|
+
return 1;
|
|
46
|
+
return undefined;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function nanoBanana2UnitPrice(
|
|
50
|
+
endpoint: FalEndpoint,
|
|
51
|
+
baseUnitPrice: number,
|
|
52
|
+
input: Record<string, unknown>,
|
|
53
|
+
): number {
|
|
54
|
+
if (
|
|
55
|
+
endpoint !== 'fal-ai/nano-banana-2' &&
|
|
56
|
+
endpoint !== 'fal-ai/nano-banana-2/edit' &&
|
|
57
|
+
endpoint !== NANO_BANANA_2_UNDERLYING_ENDPOINT
|
|
58
|
+
) {
|
|
59
|
+
return baseUnitPrice;
|
|
60
|
+
}
|
|
61
|
+
const resolutionMultiplier =
|
|
62
|
+
input['resolution'] === '0.5K'
|
|
63
|
+
? 0.75
|
|
64
|
+
: input['resolution'] === '2K'
|
|
65
|
+
? 1.5
|
|
66
|
+
: input['resolution'] === '4K'
|
|
67
|
+
? 2
|
|
68
|
+
: 1;
|
|
69
|
+
const webSearchSurcharge =
|
|
70
|
+
input['enable_web_search'] === true || input['enable_google_search'] === true ? 0.015 : 0;
|
|
71
|
+
const highThinkingSurcharge = input['thinking_level'] === 'high' ? 0.002 : 0;
|
|
72
|
+
return baseUnitPrice * resolutionMultiplier + webSearchSurcharge + highThinkingSurcharge;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
async function managedCreditEstimate(
|
|
76
|
+
options: { endpoint: FalEndpoint; input: Record<string, unknown> },
|
|
77
|
+
price: { unitPrice: number; unit: string; quantity?: number },
|
|
78
|
+
): Promise<number | undefined> {
|
|
79
|
+
const response = await fetch(
|
|
80
|
+
`${required('VGAI_GENERATION_GATEWAY').replace(/\/$/, '')}/billing/quote`,
|
|
81
|
+
{
|
|
82
|
+
method: 'POST',
|
|
83
|
+
headers: {
|
|
84
|
+
Authorization: `Bearer ${required('VGAI_ACCESS_TOKEN')}`,
|
|
85
|
+
'Content-Type': 'application/json',
|
|
86
|
+
},
|
|
87
|
+
body: JSON.stringify({
|
|
88
|
+
provider: 'fal',
|
|
89
|
+
model: options.endpoint,
|
|
90
|
+
input: options.input,
|
|
91
|
+
providerPrice: { currency: 'USD', ...price },
|
|
92
|
+
}),
|
|
93
|
+
},
|
|
94
|
+
);
|
|
95
|
+
if (!response.ok) return undefined;
|
|
96
|
+
const quote = (await response.json()) as { estimatedCredits?: unknown };
|
|
97
|
+
return typeof quote.estimatedCredits === 'number' && quote.estimatedCredits >= 0
|
|
98
|
+
? quote.estimatedCredits
|
|
99
|
+
: undefined;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export async function quoteFalPrice(options: {
|
|
103
|
+
mode: ExecutionMode;
|
|
104
|
+
endpoint: FalEndpoint;
|
|
105
|
+
input: Record<string, unknown>;
|
|
106
|
+
}): Promise<FalPriceQuote | null> {
|
|
107
|
+
if (options.mode === 'mock') return null;
|
|
108
|
+
const target = new URL('https://api.fal.ai/v1/models/pricing');
|
|
109
|
+
target.searchParams.set('endpoint_id', options.endpoint);
|
|
110
|
+
const response =
|
|
111
|
+
options.mode === 'direct'
|
|
112
|
+
? await fetch(target, { headers: { Authorization: `Key ${required('FAL_KEY')}` } })
|
|
113
|
+
: await fetch(
|
|
114
|
+
`${required('VGAI_GENERATION_GATEWAY').replace(/\/$/, '')}/fal/pricing?${target.searchParams}`,
|
|
115
|
+
{ headers: { Authorization: `Bearer ${required('VGAI_ACCESS_TOKEN')}` } },
|
|
116
|
+
);
|
|
117
|
+
if (!response.ok) throw new Error(`Fal pricing request failed with HTTP ${response.status}.`);
|
|
118
|
+
const body = (await response.json()) as { prices?: unknown };
|
|
119
|
+
const price = Array.isArray(body.prices)
|
|
120
|
+
? body.prices.find(
|
|
121
|
+
(entry): entry is Record<string, unknown> => !!entry && typeof entry === 'object',
|
|
122
|
+
)
|
|
123
|
+
: undefined;
|
|
124
|
+
if (!price || typeof price['unit_price'] !== 'number' || typeof price['unit'] !== 'string') {
|
|
125
|
+
throw new Error(`Fal returned no current price for ${options.endpoint}.`);
|
|
126
|
+
}
|
|
127
|
+
if (price['currency'] !== undefined && price['currency'] !== 'USD') {
|
|
128
|
+
throw new Error(`Fal returned unsupported pricing currency ${String(price['currency'])}.`);
|
|
129
|
+
}
|
|
130
|
+
const unitPrice = nanoBanana2UnitPrice(options.endpoint, price['unit_price'], options.input);
|
|
131
|
+
const count = quantity(price['unit'], options.input);
|
|
132
|
+
const estimatedCredits =
|
|
133
|
+
options.mode === 'managed'
|
|
134
|
+
? await managedCreditEstimate(options, {
|
|
135
|
+
unitPrice,
|
|
136
|
+
unit: price['unit'],
|
|
137
|
+
...(count === undefined ? {} : { quantity: count }),
|
|
138
|
+
})
|
|
139
|
+
: undefined;
|
|
140
|
+
return {
|
|
141
|
+
mode: options.mode,
|
|
142
|
+
endpoint: options.endpoint,
|
|
143
|
+
currency: 'USD',
|
|
144
|
+
unitPrice,
|
|
145
|
+
unit: price['unit'],
|
|
146
|
+
...(count === undefined ? {} : { quantity: count, estimatedAmount: unitPrice * count }),
|
|
147
|
+
...(estimatedCredits === undefined ? {} : { estimatedCredits }),
|
|
148
|
+
};
|
|
149
|
+
}
|
package/src/provider.ts
ADDED
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import { createFalClient, type FalClient } from '@fal-ai/client';
|
|
2
|
+
import { createDirectFalFetch } from '@vgai/fal-client-compat/direct';
|
|
3
|
+
import { createManagedFalFetch } from '@vgai/fal-client-compat/managed';
|
|
4
|
+
import { createMockFalClient } from '@vgai/fal-client-compat/mock';
|
|
5
|
+
import type { ExecutionMode } from './contracts.js';
|
|
6
|
+
|
|
7
|
+
const clients = new Map<ExecutionMode, FalClient>();
|
|
8
|
+
const fetchers = new Map<ExecutionMode, typeof fetch>();
|
|
9
|
+
const billing = new Map<
|
|
10
|
+
string,
|
|
11
|
+
{ estimatedCredits?: number; settledCredits?: number; billableUnits?: number }
|
|
12
|
+
>();
|
|
13
|
+
|
|
14
|
+
function numericHeader(response: Response, name: string): number | undefined {
|
|
15
|
+
const value = Number(response.headers.get(name));
|
|
16
|
+
return Number.isFinite(value) && value >= 0 ? value : undefined;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function observeBilling(fetcher: typeof fetch): typeof fetch {
|
|
20
|
+
return async (input, init) => {
|
|
21
|
+
const request = new Request(input, init);
|
|
22
|
+
const response = await fetcher(request);
|
|
23
|
+
const pathId = /\/requests\/([^/]+)/.exec(new URL(request.url).pathname)?.[1];
|
|
24
|
+
const requestId = response.headers.get('x-fal-request-id') ?? pathId;
|
|
25
|
+
if (requestId) {
|
|
26
|
+
const previous = billing.get(decodeURIComponent(requestId)) ?? {};
|
|
27
|
+
billing.set(decodeURIComponent(requestId), {
|
|
28
|
+
...previous,
|
|
29
|
+
...(numericHeader(response, 'X-VGAI-Estimated-Credits') === undefined
|
|
30
|
+
? {}
|
|
31
|
+
: { estimatedCredits: numericHeader(response, 'X-VGAI-Estimated-Credits')! }),
|
|
32
|
+
...(numericHeader(response, 'X-VGAI-Settled-Credits') === undefined
|
|
33
|
+
? {}
|
|
34
|
+
: { settledCredits: numericHeader(response, 'X-VGAI-Settled-Credits')! }),
|
|
35
|
+
...(numericHeader(response, 'X-Fal-Billable-Units') === undefined
|
|
36
|
+
? {}
|
|
37
|
+
: { billableUnits: numericHeader(response, 'X-Fal-Billable-Units')! }),
|
|
38
|
+
});
|
|
39
|
+
}
|
|
40
|
+
return response;
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function required(name: string): string {
|
|
45
|
+
const value = process.env[name];
|
|
46
|
+
if (!value) throw new Error(`${name} is required for this execution mode.`);
|
|
47
|
+
return value;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function falClient(mode: ExecutionMode): FalClient {
|
|
51
|
+
const existing = clients.get(mode);
|
|
52
|
+
if (existing) return existing;
|
|
53
|
+
let client: FalClient;
|
|
54
|
+
if (mode === 'mock') {
|
|
55
|
+
client = createMockFalClient();
|
|
56
|
+
} else if (mode === 'direct') {
|
|
57
|
+
const fetcher = observeBilling(createDirectFalFetch());
|
|
58
|
+
fetchers.set(mode, fetcher);
|
|
59
|
+
client = createFalClient({ credentials: required('FAL_KEY'), fetch: fetcher });
|
|
60
|
+
} else {
|
|
61
|
+
const fetcher = observeBilling(
|
|
62
|
+
createManagedFalFetch({
|
|
63
|
+
gatewayUrl: required('VGAI_GENERATION_GATEWAY'),
|
|
64
|
+
accessToken: () => required('VGAI_ACCESS_TOKEN'),
|
|
65
|
+
}),
|
|
66
|
+
);
|
|
67
|
+
fetchers.set(mode, fetcher);
|
|
68
|
+
client = createFalClient({
|
|
69
|
+
credentials: undefined,
|
|
70
|
+
proxyUrl: {
|
|
71
|
+
url: `${required('VGAI_GENERATION_GATEWAY').replace(/\/$/, '')}/fal/proxy`,
|
|
72
|
+
when: 'always',
|
|
73
|
+
},
|
|
74
|
+
fetch: fetcher,
|
|
75
|
+
});
|
|
76
|
+
}
|
|
77
|
+
clients.set(mode, client);
|
|
78
|
+
return client;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export function falBilling(requestId: string) {
|
|
82
|
+
return billing.get(requestId);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export function falArtifactFetch(mode: ExecutionMode): typeof fetch {
|
|
86
|
+
falClient(mode);
|
|
87
|
+
return fetchers.get(mode) ?? globalThis.fetch;
|
|
88
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { defineTool } from '@vgai/sdk/tools';
|
|
2
|
+
import { QuoteInputSchema, QuoteResultSchema } from './contracts.js';
|
|
3
|
+
import { quoteFalPrice } from './pricing.js';
|
|
4
|
+
import { falTool, falToolErrors } from './tool-error.js';
|
|
5
|
+
|
|
6
|
+
export const tool = defineTool({
|
|
7
|
+
name: 'project.fal.quote',
|
|
8
|
+
summary: 'Quote a Fal request',
|
|
9
|
+
description: 'Reads Fal current account pricing for one provider-native request.',
|
|
10
|
+
input: QuoteInputSchema,
|
|
11
|
+
result: QuoteResultSchema,
|
|
12
|
+
errors: falToolErrors,
|
|
13
|
+
requires: { project: true },
|
|
14
|
+
host: 'node',
|
|
15
|
+
mutates: false,
|
|
16
|
+
supportsDryRun: false,
|
|
17
|
+
longRunning: false,
|
|
18
|
+
permission: { risk: 'read', summary: 'Reads current Fal account pricing.' },
|
|
19
|
+
impl: falTool(quoteFalPrice),
|
|
20
|
+
});
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import type { GenerationToolContribution } from '@vgai/sdk/generations';
|
|
2
|
+
import { defineTool } from '@vgai/sdk/tools';
|
|
3
|
+
import {
|
|
4
|
+
SEEDANCE_2_REFERENCE_TO_VIDEO_ENDPOINT,
|
|
5
|
+
SubmissionSchema,
|
|
6
|
+
SubmitResultSchema,
|
|
7
|
+
} from './contracts.js';
|
|
8
|
+
import { submitFal } from './submit.js';
|
|
9
|
+
import { falTool, falToolErrors } from './tool-error.js';
|
|
10
|
+
|
|
11
|
+
const ENDPOINT_LABELS: Record<string, string> = {
|
|
12
|
+
'fal-ai/nano-banana-2': 'Generated image',
|
|
13
|
+
'fal-ai/nano-banana-2/edit': 'Edited image',
|
|
14
|
+
'fal-ai/gemini-3.1-flash-image-preview': 'Generated image',
|
|
15
|
+
'fal-ai/nano-banana': 'Generated image',
|
|
16
|
+
'fal-ai/nano-banana/edit': 'Edited image',
|
|
17
|
+
'fal-ai/stable-audio': 'Generated audio',
|
|
18
|
+
'fal-ai/sam-3/3d-objects': 'Reconstructed object',
|
|
19
|
+
'fal-ai/minimax/hailuo-02/standard/text-to-video': 'Generated video',
|
|
20
|
+
[SEEDANCE_2_REFERENCE_TO_VIDEO_ENDPOINT]: 'Seedance video treatment',
|
|
21
|
+
'fal-ai/hunyuan_world': 'Generated panorama',
|
|
22
|
+
'fal-ai/hunyuan_world/image-to-world': 'Generated layered world',
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
function jobLabel(endpoint: string, input: Record<string, unknown>): string {
|
|
26
|
+
const prompt = input['prompt'];
|
|
27
|
+
if (typeof prompt === 'string' && prompt.trim()) {
|
|
28
|
+
return prompt.trim().length > 64 ? `${prompt.trim().slice(0, 61)}…` : prompt.trim();
|
|
29
|
+
}
|
|
30
|
+
return ENDPOINT_LABELS[endpoint] ?? endpoint;
|
|
31
|
+
}
|
|
32
|
+
export const tool = defineTool({
|
|
33
|
+
name: 'project.fal.submit',
|
|
34
|
+
summary: 'Submit a Fal request',
|
|
35
|
+
description:
|
|
36
|
+
'Submits one provider-native Fal queue request. No project files are written until project.fal.accept.',
|
|
37
|
+
input: SubmissionSchema,
|
|
38
|
+
result: SubmitResultSchema,
|
|
39
|
+
errors: falToolErrors,
|
|
40
|
+
requires: { project: true },
|
|
41
|
+
host: 'node',
|
|
42
|
+
mutates: false,
|
|
43
|
+
supportsDryRun: false,
|
|
44
|
+
longRunning: false,
|
|
45
|
+
permission: {
|
|
46
|
+
risk: 'write',
|
|
47
|
+
summary: 'Creates an external Fal request that may incur provider charges.',
|
|
48
|
+
},
|
|
49
|
+
impl: falTool(submitFal),
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
export const generation = {
|
|
53
|
+
role: 'submit',
|
|
54
|
+
provider: 'fal',
|
|
55
|
+
toJob(inputValue, resultValue) {
|
|
56
|
+
const input = SubmissionSchema.parse(inputValue);
|
|
57
|
+
const result = SubmitResultSchema.parse(resultValue);
|
|
58
|
+
return {
|
|
59
|
+
provider: 'fal',
|
|
60
|
+
externalId: result.requestId,
|
|
61
|
+
label: jobLabel(input.endpoint, input.input),
|
|
62
|
+
operation: input.endpoint,
|
|
63
|
+
mode: input.mode,
|
|
64
|
+
status: 'queued',
|
|
65
|
+
billing:
|
|
66
|
+
input.mode === 'mock'
|
|
67
|
+
? { route: 'mock' }
|
|
68
|
+
: input.mode === 'managed'
|
|
69
|
+
? {
|
|
70
|
+
route: 'managed',
|
|
71
|
+
...(result.estimatedCredits === undefined
|
|
72
|
+
? {}
|
|
73
|
+
: { estimatedCredits: result.estimatedCredits }),
|
|
74
|
+
}
|
|
75
|
+
: {
|
|
76
|
+
route: 'byok',
|
|
77
|
+
currency: 'USD',
|
|
78
|
+
...(result.quote?.estimatedAmount === undefined
|
|
79
|
+
? {}
|
|
80
|
+
: { estimatedAmount: result.quote.estimatedAmount }),
|
|
81
|
+
},
|
|
82
|
+
poll: {
|
|
83
|
+
tool: 'project.fal.poll',
|
|
84
|
+
input: { mode: input.mode, endpoint: input.endpoint, requestId: result.requestId },
|
|
85
|
+
},
|
|
86
|
+
cancel: {
|
|
87
|
+
tool: 'project.fal.cancel',
|
|
88
|
+
input: {
|
|
89
|
+
mode: input.mode,
|
|
90
|
+
endpoint: input.endpoint,
|
|
91
|
+
requestId: result.requestId,
|
|
92
|
+
cancelUrl: result.cancelUrl,
|
|
93
|
+
},
|
|
94
|
+
},
|
|
95
|
+
accept: {
|
|
96
|
+
tool: 'project.fal.accept',
|
|
97
|
+
input: { ...input, requestId: result.requestId },
|
|
98
|
+
},
|
|
99
|
+
};
|
|
100
|
+
},
|
|
101
|
+
} satisfies GenerationToolContribution;
|
package/src/submit.ts
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import type { Submission } from './contracts.js';
|
|
2
|
+
import { quoteFalPrice } from './pricing.js';
|
|
3
|
+
import { falBilling, falClient } from './provider.js';
|
|
4
|
+
|
|
5
|
+
export async function submitFal(input: Submission) {
|
|
6
|
+
const quote = await quoteFalPrice(input);
|
|
7
|
+
const queued = await falClient(input.mode).queue.submit(input.endpoint, {
|
|
8
|
+
input: input.input as never,
|
|
9
|
+
});
|
|
10
|
+
const billing = input.mode === 'managed' ? falBilling(queued.request_id) : undefined;
|
|
11
|
+
return {
|
|
12
|
+
requestId: queued.request_id,
|
|
13
|
+
responseUrl: queued.response_url,
|
|
14
|
+
statusUrl: queued.status_url,
|
|
15
|
+
cancelUrl: queued.cancel_url,
|
|
16
|
+
mode: input.mode,
|
|
17
|
+
endpoint: input.endpoint,
|
|
18
|
+
input: input.input,
|
|
19
|
+
outputDirectory: input.outputDirectory,
|
|
20
|
+
...(quote ? { quote } : {}),
|
|
21
|
+
...(billing?.estimatedCredits === undefined && quote?.estimatedCredits === undefined
|
|
22
|
+
? {}
|
|
23
|
+
: {
|
|
24
|
+
estimatedCredits: billing?.estimatedCredits ?? quote!.estimatedCredits!,
|
|
25
|
+
}),
|
|
26
|
+
};
|
|
27
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { type ToolContext, ToolError } from '@vgai/sdk/tools';
|
|
2
|
+
|
|
3
|
+
export const falToolErrors = [
|
|
4
|
+
{ code: 'PROVIDER_ERROR', summary: 'Fal rejected the request or could not be reached.' },
|
|
5
|
+
{ code: 'REQUEST_NOT_FOUND', summary: 'Fal no longer recognizes the request id.' },
|
|
6
|
+
{ code: 'ALREADY_COMPLETED', summary: 'Fal completed the request before cancellation.' },
|
|
7
|
+
] as const;
|
|
8
|
+
|
|
9
|
+
const OPERATION_ERROR_BRAND = Symbol.for('@vgai/sdk.ToolError');
|
|
10
|
+
|
|
11
|
+
export function falTool<TInput, TResult>(
|
|
12
|
+
impl: (input: TInput, ctx: ToolContext) => Promise<TResult>,
|
|
13
|
+
): (input: TInput, ctx: ToolContext) => Promise<TResult> {
|
|
14
|
+
return async (input, ctx) => {
|
|
15
|
+
try {
|
|
16
|
+
return await impl(input, ctx);
|
|
17
|
+
} catch (cause) {
|
|
18
|
+
if (
|
|
19
|
+
cause &&
|
|
20
|
+
typeof cause === 'object' &&
|
|
21
|
+
(cause as Record<PropertyKey, unknown>)[OPERATION_ERROR_BRAND] === true
|
|
22
|
+
) {
|
|
23
|
+
throw cause;
|
|
24
|
+
}
|
|
25
|
+
const message = cause instanceof Error ? cause.message : String(cause);
|
|
26
|
+
throw new ToolError('PROVIDER_ERROR', message, { provider: 'fal', message });
|
|
27
|
+
}
|
|
28
|
+
};
|
|
29
|
+
}
|