dsh-newapi-provider 0.0.0-stage → 0.8.3
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/LICENSE +21 -0
- package/PUBLISHING.md +130 -0
- package/README.md +125 -2
- package/README.zh.md +142 -0
- package/client.js +1197 -0
- package/cordis.patch.yml +3 -0
- package/icon.svg +16 -0
- package/index.js +687 -0
- package/lib/adapter.js +191 -0
- package/lib/balance.js +121 -0
- package/lib/catalog.js +207 -0
- package/lib/channels.js +244 -0
- package/lib/media.js +679 -0
- package/lib/model-test.js +395 -0
- package/lib/modelsdev.js +138 -0
- package/lib/util.js +138 -0
- package/locale/en.json +6 -0
- package/locale/zh.json +6 -0
- package/package.json +91 -4
package/lib/media.js
ADDED
|
@@ -0,0 +1,679 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Image and video generation through the New API gateway.
|
|
3
|
+
*
|
|
4
|
+
* Images: POST /v1/images/generations and /v1/images/edits. New API answers
|
|
5
|
+
* synchronously (it polls async vendors inside the request for up to 600 s),
|
|
6
|
+
* so the tool waits with a long timeout. Results are saved as DSH image
|
|
7
|
+
* attachments so the model and the chat both see them.
|
|
8
|
+
*
|
|
9
|
+
* Videos: New API's unified OpenAI Video protocol
|
|
10
|
+
* POST /v1/videos submit (JSON, `model` picks the vendor)
|
|
11
|
+
* GET /v1/videos/{id} status / progress
|
|
12
|
+
* GET /v1/videos/{id}/content finished bytes, proxied by the gateway
|
|
13
|
+
* The tool returns as soon as the task is accepted; a host poller follows the
|
|
14
|
+
* task, saves the file as a DSH attachment and wakes the agent through a
|
|
15
|
+
* background job. Tasks are written to disk, so polling resumes after a
|
|
16
|
+
* restart.
|
|
17
|
+
*
|
|
18
|
+
* Paid submissions are never retried automatically. A submission whose outcome
|
|
19
|
+
* is unknown (timeout after sending) is recorded as `submit-unknown` and
|
|
20
|
+
* reported to the model instead of being sent again.
|
|
21
|
+
*/
|
|
22
|
+
import fs from 'node:fs';
|
|
23
|
+
import { mediaModels, upstreamModelOf } from './catalog.js';
|
|
24
|
+
import { registerAsyncEffect } from './channels.js';
|
|
25
|
+
import { fetchJson, gatewayRoot, GatewayError, readCache, writeCache } from './util.js';
|
|
26
|
+
|
|
27
|
+
const IMAGE_TIMEOUT_MS = 11 * 60 * 1000;
|
|
28
|
+
const VIDEO_SUBMIT_TIMEOUT_MS = 120_000;
|
|
29
|
+
const POLL_BASE_MS = 8_000;
|
|
30
|
+
const POLL_MAX_MS = 60_000;
|
|
31
|
+
const VIDEO_GIVE_UP_MS = 6 * 60 * 60 * 1000;
|
|
32
|
+
const TASKS_FILE = 'video-tasks.json';
|
|
33
|
+
const HISTORY_FILE = 'media-history.json';
|
|
34
|
+
const HISTORY_LIMIT = 200;
|
|
35
|
+
|
|
36
|
+
const IMAGE_SIZES = ['auto', '1024x1024', '1536x1024', '1024x1536', '1792x1024', '1024x1792', '512x512', '256x256'];
|
|
37
|
+
|
|
38
|
+
function sniffImage(bytes) {
|
|
39
|
+
const b = bytes;
|
|
40
|
+
if (b[0] === 0x89 && b[1] === 0x50 && b[2] === 0x4e && b[3] === 0x47) return 'image/png';
|
|
41
|
+
if (b[0] === 0xff && b[1] === 0xd8 && b[2] === 0xff) return 'image/jpeg';
|
|
42
|
+
if (b[0] === 0x52 && b[1] === 0x49 && b[2] === 0x46 && b[3] === 0x46 && b[8] === 0x57 && b[9] === 0x45 && b[10] === 0x42 && b[11] === 0x50) return 'image/webp';
|
|
43
|
+
if (b[0] === 0x47 && b[1] === 0x49 && b[2] === 0x46) return 'image/gif';
|
|
44
|
+
return undefined;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function extOfVideo(contentType) {
|
|
48
|
+
if (/webm/i.test(contentType ?? '')) return 'webm';
|
|
49
|
+
if (/quicktime/i.test(contentType ?? '')) return 'mov';
|
|
50
|
+
return 'mp4';
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
async function downloadBytes(url, { apiKey, headers = {}, signal, timeoutMs = 120_000, maxBytes = 512 * 1024 * 1024 } = {}) {
|
|
54
|
+
const requestSignal = AbortSignal.any([AbortSignal.timeout(timeoutMs), ...(signal ? [signal] : [])]);
|
|
55
|
+
const originalOrigin = new URL(url).origin;
|
|
56
|
+
let res;
|
|
57
|
+
for (let redirects = 0; ; redirects++) {
|
|
58
|
+
res = await fetch(url, {
|
|
59
|
+
redirect: 'manual', signal: requestSignal,
|
|
60
|
+
headers: new URL(url).origin === originalOrigin ? { ...headers, ...(apiKey ? { authorization: `Bearer ${apiKey}` } : {}) } : {},
|
|
61
|
+
});
|
|
62
|
+
if (res.status < 300 || res.status >= 400) break;
|
|
63
|
+
await res.body?.cancel();
|
|
64
|
+
const location = res.headers.get('location');
|
|
65
|
+
if (!location || redirects >= 5) throw new GatewayError('download redirect limit or missing location', 'REDIRECT');
|
|
66
|
+
url = new URL(location, url).href;
|
|
67
|
+
}
|
|
68
|
+
if (!res.ok) throw new GatewayError(`download failed: HTTP ${res.status}`, res.status === 401 || res.status === 403 ? 'AUTH' : 'HTTP', { status: res.status });
|
|
69
|
+
const len = Number(res.headers.get('content-length'));
|
|
70
|
+
if (Number.isFinite(len) && len > maxBytes) throw new GatewayError(`file larger than ${maxBytes} bytes`, 'TOO_LARGE');
|
|
71
|
+
const buf = new Uint8Array(await res.arrayBuffer());
|
|
72
|
+
if (buf.byteLength > maxBytes) throw new GatewayError(`file larger than ${maxBytes} bytes`, 'TOO_LARGE');
|
|
73
|
+
return { bytes: buf, contentType: res.headers.get('content-type') ?? '' };
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** Normalize the many status words vendors use into four states. */
|
|
77
|
+
export function normalizeStatus(raw) {
|
|
78
|
+
const s = String(raw ?? '').toLowerCase();
|
|
79
|
+
if (['completed', 'succeeded', 'success', 'done', 'finished'].includes(s)) return 'completed';
|
|
80
|
+
if (['failed', 'failure', 'error', 'cancelled', 'canceled', 'expired', 'rejected'].includes(s)) return 'failed';
|
|
81
|
+
if (['queued', 'pending', 'submitted', 'not_start', 'not_started', 'waiting'].includes(s)) return 'queued';
|
|
82
|
+
return 'in_progress';
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function pickModel(catalog, kind, requested) {
|
|
86
|
+
const candidates = mediaModels(catalog, kind);
|
|
87
|
+
if (requested) {
|
|
88
|
+
const exact = candidates.find((m) => m.id === requested) ?? catalog.entries.find((m) => m.id === requested && !m.hidden);
|
|
89
|
+
if (!exact) {
|
|
90
|
+
const names = candidates.map((m) => m.id).join(', ') || '(none)';
|
|
91
|
+
throw new GatewayError(`model "${requested}" is not a ${kind} model on this gateway; available ${kind} models: ${names}`, 'UNKNOWN_MODEL');
|
|
92
|
+
}
|
|
93
|
+
return exact;
|
|
94
|
+
}
|
|
95
|
+
if (candidates.length === 0) {
|
|
96
|
+
throw new GatewayError(`the gateway lists no ${kind} model; add a ${kind} channel in New API, or mark a model with kind "${kind}" in this plugin's settings`, 'NO_MODEL');
|
|
97
|
+
}
|
|
98
|
+
return candidates[0];
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
class TaskStore {
|
|
102
|
+
constructor() {
|
|
103
|
+
const loaded = readCache(TASKS_FILE);
|
|
104
|
+
this.tasks = new Map(Object.entries(loaded?.tasks ?? {}));
|
|
105
|
+
}
|
|
106
|
+
get(id) { return this.tasks.get(id); }
|
|
107
|
+
all() { return [...this.tasks.values()]; }
|
|
108
|
+
put(task) {
|
|
109
|
+
this.tasks.set(task.localId, task);
|
|
110
|
+
this.flush();
|
|
111
|
+
return task;
|
|
112
|
+
}
|
|
113
|
+
flush() {
|
|
114
|
+
writeCache(TASKS_FILE, { version: 1, tasks: Object.fromEntries(this.tasks) });
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
class History {
|
|
119
|
+
constructor() {
|
|
120
|
+
this.items = readCache(HISTORY_FILE)?.items ?? [];
|
|
121
|
+
}
|
|
122
|
+
add(item) {
|
|
123
|
+
this.items.unshift(item);
|
|
124
|
+
if (this.items.length > HISTORY_LIMIT) this.items.length = HISTORY_LIMIT;
|
|
125
|
+
writeCache(HISTORY_FILE, { version: 1, items: this.items });
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
export function registerMedia(ctx, host) {
|
|
130
|
+
const { reportError } = host;
|
|
131
|
+
// Support callers of the former single-channel interface without global URL reads.
|
|
132
|
+
const capture = host.capture ?? (() => ({
|
|
133
|
+
connection: { channelId: 'default', apiKeyName: 'NEWAPI_API_KEY', ...host.connection() },
|
|
134
|
+
requireApiKey: host.requireApiKey, catalog: host.catalog,
|
|
135
|
+
}));
|
|
136
|
+
const captureTask = host.captureTask ?? ((task) => {
|
|
137
|
+
const bound = capture();
|
|
138
|
+
if (!task.origin || gatewayRoot(bound.connection.baseURL) !== task.origin.baseURL) throw new Error('video origin unavailable');
|
|
139
|
+
return bound;
|
|
140
|
+
});
|
|
141
|
+
const lifetime = new AbortController();
|
|
142
|
+
const activeSignal = (signal) => AbortSignal.any([lifetime.signal, ...(signal ? [signal] : [])]);
|
|
143
|
+
const ensureActive = () => lifetime.signal.throwIfAborted();
|
|
144
|
+
const tasks = new TaskStore();
|
|
145
|
+
const putTask = (task) => { ensureActive(); return tasks.put(task); };
|
|
146
|
+
const history = new History();
|
|
147
|
+
const pollers = new Map();
|
|
148
|
+
const polling = new Map();
|
|
149
|
+
const jobWaiters = new Map();
|
|
150
|
+
let stopped = false;
|
|
151
|
+
ctx.effect(() => () => {
|
|
152
|
+
stopped = true;
|
|
153
|
+
lifetime.abort(new Error("media plugin unloaded"));
|
|
154
|
+
for (const t of pollers.values()) clearTimeout(t);
|
|
155
|
+
pollers.clear();
|
|
156
|
+
for (const waiter of jobWaiters.values()) waiter.resolve({ status: 'killed', detail: 'plugin unloaded; gateway task remains tracked' });
|
|
157
|
+
jobWaiters.clear();
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
const sameGateway = (url, root) => {
|
|
161
|
+
try { const target = new URL(url); const base = new URL(`${root}/`); return target.origin === base.origin && target.pathname.startsWith(base.pathname); }
|
|
162
|
+
catch { return false; }
|
|
163
|
+
};
|
|
164
|
+
|
|
165
|
+
// ---------------------------------------------------------------- images
|
|
166
|
+
|
|
167
|
+
async function generateImages(args, signal) {
|
|
168
|
+
signal = activeSignal(signal);
|
|
169
|
+
ensureActive();
|
|
170
|
+
const bound = capture();
|
|
171
|
+
const root = gatewayRoot(bound.connection.baseURL);
|
|
172
|
+
const apiKey = await bound.requireApiKey();
|
|
173
|
+
const cat = await bound.catalog({ signal });
|
|
174
|
+
const entry = pickModel(cat, 'image', args.model);
|
|
175
|
+
const model = entry.id, upstreamModel = upstreamModelOf(entry);
|
|
176
|
+
const n = Math.min(Math.max(Number(args.n) || 1, 1), 4);
|
|
177
|
+
const body = {
|
|
178
|
+
model: upstreamModel,
|
|
179
|
+
prompt: String(args.prompt),
|
|
180
|
+
n,
|
|
181
|
+
...(args.size && args.size !== 'auto' ? { size: args.size } : {}),
|
|
182
|
+
...(args.quality ? { quality: args.quality } : {}),
|
|
183
|
+
...(args.background ? { background: args.background } : {}),
|
|
184
|
+
};
|
|
185
|
+
let response;
|
|
186
|
+
const started = Date.now();
|
|
187
|
+
ensureActive();
|
|
188
|
+
try {
|
|
189
|
+
if (args.images?.length) {
|
|
190
|
+
const form = new FormData();
|
|
191
|
+
for (const [k, v] of Object.entries(body)) form.append(k, String(v));
|
|
192
|
+
for (const [i, img] of args.images.entries()) {
|
|
193
|
+
form.append(args.images.length > 1 ? 'image[]' : 'image', new Blob([img.data], { type: img.mediaType }), img.name ?? `input-${i}.png`);
|
|
194
|
+
}
|
|
195
|
+
response = await fetchJson(`${root}/v1/images/edits`, { apiKey, headers: bound.connection.headers, method: 'POST', body: form, timeoutMs: IMAGE_TIMEOUT_MS, signal, maxBytes: 256 * 1024 * 1024 });
|
|
196
|
+
} else {
|
|
197
|
+
response = await fetchJson(`${root}/v1/images/generations`, { apiKey, headers: bound.connection.headers, method: 'POST', body, timeoutMs: IMAGE_TIMEOUT_MS, signal, maxBytes: 256 * 1024 * 1024 });
|
|
198
|
+
}
|
|
199
|
+
const items = Array.isArray(response?.data) ? response.data : [];
|
|
200
|
+
if (items.length === 0) throw new GatewayError('the gateway returned no images', 'EMPTY_RESPONSE');
|
|
201
|
+
const attachments = ctx.get('attachments');
|
|
202
|
+
if (!attachments) throw new GatewayError('the DSH attachment service is unavailable', 'NO_ATTACHMENTS');
|
|
203
|
+
const saved = [];
|
|
204
|
+
for (const [i, item] of items.entries()) {
|
|
205
|
+
let bytes;
|
|
206
|
+
if (item.b64_json) bytes = new Uint8Array(Buffer.from(item.b64_json, 'base64'));
|
|
207
|
+
else if (item.url) bytes = (await downloadBytes(item.url, { apiKey: sameGateway(item.url, root) ? apiKey : undefined, headers: sameGateway(item.url, root) ? bound.connection.headers : {}, signal })).bytes;
|
|
208
|
+
else continue;
|
|
209
|
+
const mediaType = sniffImage(bytes);
|
|
210
|
+
if (!mediaType) throw new GatewayError('the gateway returned an image in an unsupported format', 'BAD_RESPONSE');
|
|
211
|
+
ensureActive();
|
|
212
|
+
const ref = await attachments.saveImage({ data: bytes, mediaType, name: `newapi-${model}-${Date.now()}-${i}.${mediaType.split('/')[1]}` });
|
|
213
|
+
saved.push({ ref, revisedPrompt: item.revised_prompt ?? null });
|
|
214
|
+
}
|
|
215
|
+
const result = {
|
|
216
|
+
model, upstreamModel,
|
|
217
|
+
prompt: body.prompt,
|
|
218
|
+
size: body.size ?? 'auto',
|
|
219
|
+
elapsedMs: Date.now() - started,
|
|
220
|
+
images: saved.map((s) => s.ref),
|
|
221
|
+
revisedPrompts: saved.map((s) => s.revisedPrompt).filter(Boolean),
|
|
222
|
+
usage: response.usage ?? null,
|
|
223
|
+
};
|
|
224
|
+
ensureActive();
|
|
225
|
+
history.add({ type: 'image', at: Date.now(), model, upstreamModel, prompt: body.prompt, images: result.images });
|
|
226
|
+
// Tool values must be lossless JSON (no undefined, no class instances).
|
|
227
|
+
return JSON.parse(JSON.stringify(result));
|
|
228
|
+
} catch (error) {
|
|
229
|
+
error.mayBeBilled = true;
|
|
230
|
+
throw error;
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/**
|
|
235
|
+
* Read input images from paths the model names. Relative paths resolve
|
|
236
|
+
* against the session's working directory; reads go through the DSH
|
|
237
|
+
* filesystem service so its sandbox policy applies.
|
|
238
|
+
*/
|
|
239
|
+
async function readInputImages(paths, exec) {
|
|
240
|
+
if (!paths?.length) return [];
|
|
241
|
+
const fsService = ctx.get('fs');
|
|
242
|
+
const wd = ctx.get('workingDirectory');
|
|
243
|
+
let cwd;
|
|
244
|
+
try { cwd = exec.agent && wd ? await wd.ensure(exec.agent, exec.signal) : undefined; } catch { cwd = undefined; }
|
|
245
|
+
const out = [];
|
|
246
|
+
for (const p of paths.slice(0, 4)) {
|
|
247
|
+
let data;
|
|
248
|
+
if (fsService) {
|
|
249
|
+
const target = await fsService.resolve(String(p), { cwd, signal: exec.signal });
|
|
250
|
+
data = await fsService.readBytes(target, exec.signal, 25 * 1024 * 1024);
|
|
251
|
+
} else {
|
|
252
|
+
data = new Uint8Array(fs.readFileSync(String(p)));
|
|
253
|
+
}
|
|
254
|
+
const mediaType = sniffImage(data);
|
|
255
|
+
if (!mediaType) throw new GatewayError(`${p} is not a PNG, JPEG, WebP or GIF image`, 'INVALID_REQUEST');
|
|
256
|
+
out.push({ data, mediaType, name: String(p).split('/').pop() });
|
|
257
|
+
}
|
|
258
|
+
return out;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
ctx.inject(['tools'], (tctx) => {
|
|
262
|
+
registerAsyncEffect(tctx, () => tctx.tools.register({
|
|
263
|
+
name: 'newapi_image_generate',
|
|
264
|
+
description: [
|
|
265
|
+
'Generate or edit images with an image model on the New API gateway (for example gpt-image, DALL·E, Flux, Seedream, Imagen).',
|
|
266
|
+
'Returns the images as attachments you can see. Generation can take up to several minutes and is billed per call: do not call again just because it is slow, and do not retry a failed call without telling the user.',
|
|
267
|
+
'To edit or use reference images, pass `inputImages` with file paths (relative to the working directory or absolute); images the user attached in chat must first exist as files.',
|
|
268
|
+
].join(' '),
|
|
269
|
+
parameters: {
|
|
270
|
+
type: 'object',
|
|
271
|
+
properties: {
|
|
272
|
+
prompt: { type: 'string', description: 'What to draw, or how to change the input images.' },
|
|
273
|
+
model: { type: 'string', description: 'Local image model id from newapi_models. Omit to use the first image model; upstream mapping applies automatically.' },
|
|
274
|
+
size: { type: 'string', enum: IMAGE_SIZES, description: 'Output size; omit or "auto" for the model default.' },
|
|
275
|
+
n: { type: 'integer', minimum: 1, maximum: 4, description: 'Number of images (1-4).' },
|
|
276
|
+
quality: { type: 'string', description: 'Optional quality hint understood by the model (for example low, medium, high, hd).' },
|
|
277
|
+
background: { type: 'string', enum: ['auto', 'transparent', 'opaque'], description: 'Background handling for models that support it.' },
|
|
278
|
+
inputImages: { type: 'array', items: { type: 'string' }, maxItems: 4, description: 'Image files to edit or use as references (PNG, JPEG, WebP or GIF). Uses /v1/images/edits.' },
|
|
279
|
+
},
|
|
280
|
+
required: ['prompt'],
|
|
281
|
+
additionalProperties: false,
|
|
282
|
+
},
|
|
283
|
+
timeoutMs: IMAGE_TIMEOUT_MS + 60_000,
|
|
284
|
+
output: {
|
|
285
|
+
schema: {
|
|
286
|
+
type: 'object',
|
|
287
|
+
properties: {
|
|
288
|
+
model: { type: 'string' }, upstreamModel: { type: 'string' },
|
|
289
|
+
prompt: { type: 'string' },
|
|
290
|
+
size: { type: 'string' },
|
|
291
|
+
elapsedMs: { type: 'number' },
|
|
292
|
+
images: { type: 'array' },
|
|
293
|
+
revisedPrompts: { type: 'array', items: { type: 'string' } },
|
|
294
|
+
},
|
|
295
|
+
},
|
|
296
|
+
render(_args, value) {
|
|
297
|
+
const lines = [`Generated ${value.images.length} image(s) with ${value.model} in ${Math.round(value.elapsedMs / 1000)} s.`];
|
|
298
|
+
if (value.revisedPrompts?.length) lines.push(`Revised prompt: ${value.revisedPrompts[0]}`);
|
|
299
|
+
return [{ type: 'text', text: lines.join('\n') }, ...value.images.map((attachment) => ({ type: 'image', attachment }))];
|
|
300
|
+
},
|
|
301
|
+
presentationMeta(_args, value) {
|
|
302
|
+
return { model: value.model, size: value.size, elapsedMs: value.elapsedMs, count: value.images.length };
|
|
303
|
+
},
|
|
304
|
+
},
|
|
305
|
+
async execute(args, exec) {
|
|
306
|
+
try {
|
|
307
|
+
const images = await readInputImages(args.inputImages, exec);
|
|
308
|
+
return await generateImages({ ...args, images }, exec.signal);
|
|
309
|
+
} catch (error) {
|
|
310
|
+
reportError('image', error);
|
|
311
|
+
const paid = error.mayBeBilled ? ' The gateway may still finish and bill this request; check before retrying.' : '';
|
|
312
|
+
throw new Error(`${error.message}${paid}`);
|
|
313
|
+
}
|
|
314
|
+
},
|
|
315
|
+
}));
|
|
316
|
+
|
|
317
|
+
// -------------------------------------------------------------- videos
|
|
318
|
+
|
|
319
|
+
registerAsyncEffect(tctx, () => tctx.tools.register({
|
|
320
|
+
name: 'newapi_video_generate',
|
|
321
|
+
description: [
|
|
322
|
+
'Start a video generation task with a video model on the New API gateway (for example Sora, Veo, Kling, Seedance, Hailuo, Vidu, Jimeng).',
|
|
323
|
+
'Returns immediately with a task id; the video is produced in the background (often several minutes) and you are notified when it finishes, with the video saved as an attachment.',
|
|
324
|
+
'Billed per task: never submit the same request twice. Use newapi_video_status to check progress instead of resubmitting.',
|
|
325
|
+
].join(' '),
|
|
326
|
+
parameters: {
|
|
327
|
+
type: 'object',
|
|
328
|
+
properties: {
|
|
329
|
+
prompt: { type: 'string', description: 'Description of the video.' },
|
|
330
|
+
model: { type: 'string', description: 'Local video model id from newapi_models. Omit to use the first video model; upstream mapping applies automatically.' },
|
|
331
|
+
seconds: { type: 'integer', minimum: 1, maximum: 60, description: 'Duration in seconds, if the model supports it.' },
|
|
332
|
+
size: { type: 'string', description: 'Resolution such as 1280x720 or 720x1280, if the model supports it.' },
|
|
333
|
+
firstFrame: { type: 'string', description: 'Image file to use as the first frame / reference (image-to-video), if the model supports it.' },
|
|
334
|
+
},
|
|
335
|
+
required: ['prompt'],
|
|
336
|
+
additionalProperties: false,
|
|
337
|
+
},
|
|
338
|
+
timeoutMs: VIDEO_SUBMIT_TIMEOUT_MS + 30_000,
|
|
339
|
+
output: {
|
|
340
|
+
schema: { type: 'object', properties: { taskId: { type: 'string' }, model: { type: 'string' }, upstreamModel: { type: 'string' }, status: { type: 'string' }, jobId: { oneOf: [{ type: 'string' }, { type: 'null' }] } } },
|
|
341
|
+
render(_args, value) {
|
|
342
|
+
return [{ type: 'text', text: `Video task ${value.taskId} submitted to ${value.model} (status: ${value.status}). It runs in the background; you will be notified when it finishes${value.jobId ? ` (job ${value.jobId})` : ''}. Do not resubmit.` }];
|
|
343
|
+
},
|
|
344
|
+
},
|
|
345
|
+
async execute(args, exec) {
|
|
346
|
+
try {
|
|
347
|
+
return await submitVideo(args, exec);
|
|
348
|
+
} catch (error) {
|
|
349
|
+
reportError('video', error);
|
|
350
|
+
throw new Error(error.message);
|
|
351
|
+
}
|
|
352
|
+
},
|
|
353
|
+
}));
|
|
354
|
+
|
|
355
|
+
registerAsyncEffect(tctx, () => tctx.tools.register({
|
|
356
|
+
name: 'newapi_video_status',
|
|
357
|
+
description: 'Check video tasks started with newapi_video_generate. Without taskId, lists recent tasks. A finished task includes the saved video attachment.',
|
|
358
|
+
parameters: {
|
|
359
|
+
type: 'object',
|
|
360
|
+
properties: { taskId: { type: 'string', description: 'Task id returned by newapi_video_generate.' } },
|
|
361
|
+
additionalProperties: false,
|
|
362
|
+
},
|
|
363
|
+
isConcurrencySafe: () => true,
|
|
364
|
+
output: {
|
|
365
|
+
schema: { type: 'object', properties: { tasks: { type: 'array' } } },
|
|
366
|
+
render(_args, value) {
|
|
367
|
+
const blocks = [{ type: 'text', text: JSON.stringify(value.tasks.map(publicTask), null, 1) }];
|
|
368
|
+
for (const t of value.tasks) if (t.file) blocks.push({ type: 'file', attachment: t.file });
|
|
369
|
+
return blocks;
|
|
370
|
+
},
|
|
371
|
+
},
|
|
372
|
+
async execute(args) {
|
|
373
|
+
const list = args.taskId ? [tasks.get(args.taskId)].filter(Boolean) : tasks.all().sort((a, b) => b.createdAt - a.createdAt).slice(0, 10);
|
|
374
|
+
if (args.taskId && list.length === 0) throw new Error(`unknown video task ${args.taskId}`);
|
|
375
|
+
for (const t of list) if (!isTerminal(t)) await pollOnce(t.localId).catch(() => {});
|
|
376
|
+
return JSON.parse(JSON.stringify({ tasks: list.map((t) => tasks.get(t.localId)) }));
|
|
377
|
+
},
|
|
378
|
+
}));
|
|
379
|
+
});
|
|
380
|
+
|
|
381
|
+
function publicTask(t) {
|
|
382
|
+
return {
|
|
383
|
+
taskId: t.localId,
|
|
384
|
+
model: t.model,
|
|
385
|
+
upstreamModel: t.upstreamModel ?? t.model,
|
|
386
|
+
channelId: t.origin?.channelId ?? null,
|
|
387
|
+
status: t.status,
|
|
388
|
+
remoteId: t.remoteId ?? null,
|
|
389
|
+
downloadDeadline: t.downloadDeadline ?? null,
|
|
390
|
+
lastPollError: t.lastPollError ?? null,
|
|
391
|
+
progress: t.progress ?? null,
|
|
392
|
+
error: t.error ?? null,
|
|
393
|
+
createdAt: new Date(t.createdAt).toISOString(),
|
|
394
|
+
finishedAt: t.finishedAt ? new Date(t.finishedAt).toISOString() : null,
|
|
395
|
+
file: t.file ? { name: t.file.name, bytes: t.file.bytes } : null,
|
|
396
|
+
};
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
const isTerminal = (t) => ['completed', 'failed', 'submit-unknown', 'expired', 'download-failed'].includes(t.status);
|
|
400
|
+
|
|
401
|
+
async function submitVideo(args, exec, captured) {
|
|
402
|
+
exec = { ...exec, signal: activeSignal(exec.signal) };
|
|
403
|
+
ensureActive();
|
|
404
|
+
const bound = captured ?? capture(args.channelId);
|
|
405
|
+
const conn = bound.connection;
|
|
406
|
+
const root = gatewayRoot(conn.baseURL);
|
|
407
|
+
const apiKey = await bound.requireApiKey();
|
|
408
|
+
const cat = await bound.catalog({ signal: exec.signal });
|
|
409
|
+
const entry = pickModel(cat, 'video', args.model);
|
|
410
|
+
const model = entry.id, upstreamModel = upstreamModelOf(entry);
|
|
411
|
+
const firstFrames = args.firstFrame ? await readInputImages([args.firstFrame], exec) : [];
|
|
412
|
+
ensureActive();
|
|
413
|
+
const localId = `nv-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`;
|
|
414
|
+
const task = putTask({
|
|
415
|
+
localId,
|
|
416
|
+
remoteId: null,
|
|
417
|
+
origin: { channelId: conn.channelId, baseURL: root, keyName: conn.apiKeyName },
|
|
418
|
+
model, upstreamModel,
|
|
419
|
+
prompt: String(args.prompt),
|
|
420
|
+
status: 'submitting',
|
|
421
|
+
createdAt: Date.now(),
|
|
422
|
+
owner: exec.agent?.id ?? null,
|
|
423
|
+
callId: exec.callId,
|
|
424
|
+
attempts: 0,
|
|
425
|
+
});
|
|
426
|
+
|
|
427
|
+
let response;
|
|
428
|
+
try {
|
|
429
|
+
const fields = {
|
|
430
|
+
model: upstreamModel,
|
|
431
|
+
prompt: task.prompt,
|
|
432
|
+
...(args.seconds ? { seconds: String(args.seconds) } : {}),
|
|
433
|
+
...(args.size ? { size: args.size } : {}),
|
|
434
|
+
};
|
|
435
|
+
if (firstFrames.length) {
|
|
436
|
+
const [img] = firstFrames;
|
|
437
|
+
const form = new FormData();
|
|
438
|
+
for (const [k, v] of Object.entries(fields)) form.append(k, v);
|
|
439
|
+
form.append('input_reference', new Blob([img.data], { type: img.mediaType }), img.name ?? 'first-frame.png');
|
|
440
|
+
response = await fetchJson(`${root}/v1/videos`, { apiKey, headers: bound.connection.headers, method: 'POST', body: form, timeoutMs: VIDEO_SUBMIT_TIMEOUT_MS, signal: exec.signal });
|
|
441
|
+
} else {
|
|
442
|
+
response = await fetchJson(`${root}/v1/videos`, { apiKey, headers: bound.connection.headers, method: 'POST', body: fields, timeoutMs: VIDEO_SUBMIT_TIMEOUT_MS, signal: exec.signal });
|
|
443
|
+
}
|
|
444
|
+
} catch (error) {
|
|
445
|
+
// A timeout or dropped connection after the request was sent may still
|
|
446
|
+
// have created a billed task upstream; never resend automatically.
|
|
447
|
+
const unknown = ['TIMEOUT', 'UNREACHABLE', 'BAD_RESPONSE', 'TOO_LARGE'].includes(error.code);
|
|
448
|
+
putTask({ ...task, status: unknown ? 'submit-unknown' : 'failed', error: error.message, finishedAt: Date.now() });
|
|
449
|
+
throw new GatewayError(
|
|
450
|
+
unknown
|
|
451
|
+
? `${error.message}. The gateway may have accepted and billed this task; check the New API task log before submitting again.`
|
|
452
|
+
: error.message,
|
|
453
|
+
error.code,
|
|
454
|
+
);
|
|
455
|
+
}
|
|
456
|
+
const remoteId = response?.id ?? response?.task_id ?? response?.data?.id ?? response?.data?.task_id;
|
|
457
|
+
if (!remoteId) {
|
|
458
|
+
putTask({ ...task, status: 'submit-unknown', error: 'gateway response carried no task id', finishedAt: Date.now() });
|
|
459
|
+
throw new GatewayError('the gateway accepted the request but returned no task id; check the New API task log before submitting again', 'BAD_RESPONSE');
|
|
460
|
+
}
|
|
461
|
+
const accepted = putTask({
|
|
462
|
+
...task,
|
|
463
|
+
remoteId: String(remoteId),
|
|
464
|
+
status: normalizeStatus(response.status ?? 'queued') === 'completed' ? 'download-pending' : normalizeStatus(response.status ?? 'queued'),
|
|
465
|
+
...(normalizeStatus(response.status) === 'completed' ? { downloadDeadline: Date.now() + VIDEO_GIVE_UP_MS, statusDoc: response } : {}),
|
|
466
|
+
progress: response.progress ?? null,
|
|
467
|
+
});
|
|
468
|
+
const jobId = startJob(accepted, exec);
|
|
469
|
+
putTask({ ...tasks.get(localId), jobId });
|
|
470
|
+
schedulePoll(localId, POLL_BASE_MS);
|
|
471
|
+
history.add({ type: 'video-task', at: Date.now(), model, upstreamModel, prompt: task.prompt, taskId: localId });
|
|
472
|
+
return JSON.parse(JSON.stringify({ taskId: localId, model, upstreamModel, status: accepted.status, jobId: jobId ?? null }));
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
/** A background job carries progress to the jobs panel and wakes the agent on completion. */
|
|
476
|
+
function startJob(task, exec) {
|
|
477
|
+
const jobs = ctx.get('jobs');
|
|
478
|
+
if (!jobs || !exec?.agent?.id) return undefined;
|
|
479
|
+
try {
|
|
480
|
+
return jobs.start({
|
|
481
|
+
kind: 'newapi-video',
|
|
482
|
+
label: `New API video · ${task.model} · ${task.prompt.slice(0, 40)}`,
|
|
483
|
+
owner: exec.agent.id,
|
|
484
|
+
toolCall: { callId: exec.callId, rootCallId: exec.rootCallId ?? exec.callId },
|
|
485
|
+
run(handle) {
|
|
486
|
+
let resolve;
|
|
487
|
+
const done = new Promise((r) => { resolve = r; });
|
|
488
|
+
jobWaiters.set(task.localId, { handle, resolve });
|
|
489
|
+
handle.updateProgress(`${task.status}`);
|
|
490
|
+
return {
|
|
491
|
+
cancel(reason) {
|
|
492
|
+
jobWaiters.delete(task.localId);
|
|
493
|
+
resolve({ status: 'killed', detail: `${reason ?? 'cancelled'}; the gateway task ${task.remoteId} may keep running and is still tracked by newapi_video_status` });
|
|
494
|
+
},
|
|
495
|
+
done,
|
|
496
|
+
};
|
|
497
|
+
},
|
|
498
|
+
});
|
|
499
|
+
} catch (error) {
|
|
500
|
+
ctx.logger.warn(`newapi-provider: could not start a background job for video task ${task.localId}: ${error.message}`);
|
|
501
|
+
return undefined;
|
|
502
|
+
}
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
function settleJob(task) {
|
|
506
|
+
const waiter = jobWaiters.get(task.localId);
|
|
507
|
+
if (!waiter) return;
|
|
508
|
+
jobWaiters.delete(task.localId);
|
|
509
|
+
if (task.status === 'completed') {
|
|
510
|
+
waiter.handle.append(`video saved: ${task.file?.name} (${task.file?.bytes} bytes)\n`);
|
|
511
|
+
waiter.resolve({ status: 'completed', result: JSON.stringify({ taskId: task.localId, model: task.model, upstreamModel: task.upstreamModel ?? task.model, file: task.file, hostPath: task.hostPath ?? null, note: 'Call newapi_video_status with this taskId to attach the video to the conversation.' }) });
|
|
512
|
+
} else {
|
|
513
|
+
waiter.resolve({ status: 'failed', detail: task.error ?? task.status, result: JSON.stringify({ taskId: task.localId, status: task.status, error: task.error ?? null }) });
|
|
514
|
+
}
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
function schedulePoll(localId, delay) {
|
|
518
|
+
if (stopped) return;
|
|
519
|
+
clearTimeout(pollers.get(localId));
|
|
520
|
+
pollers.set(localId, setTimeout(() => {
|
|
521
|
+
pollers.delete(localId);
|
|
522
|
+
pollOnce(localId)
|
|
523
|
+
.then((t) => {
|
|
524
|
+
if (t && !isTerminal(t)) schedulePoll(localId, Math.min(POLL_MAX_MS, POLL_BASE_MS * 2 ** Math.min(t.attempts ?? 0, 3)));
|
|
525
|
+
})
|
|
526
|
+
.catch((error) => {
|
|
527
|
+
const t = tasks.get(localId);
|
|
528
|
+
if (!t || stopped) return;
|
|
529
|
+
const wait = error.code === 'RATE_LIMIT' ? (error.retryAfterMs ?? POLL_MAX_MS) : POLL_MAX_MS;
|
|
530
|
+
ctx.logger.warn(`newapi-provider: polling video task ${localId} failed: ${error.message}`);
|
|
531
|
+
putTask({ ...t, lastPollError: error.message });
|
|
532
|
+
schedulePoll(localId, wait);
|
|
533
|
+
});
|
|
534
|
+
}, delay));
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
function pollOnce(localId) {
|
|
538
|
+
if (stopped) return Promise.resolve(tasks.get(localId));
|
|
539
|
+
if (polling.has(localId)) return polling.get(localId);
|
|
540
|
+
const pending = pollTask(localId).finally(() => polling.delete(localId));
|
|
541
|
+
polling.set(localId, pending);
|
|
542
|
+
return pending;
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
async function pollTask(localId) {
|
|
546
|
+
const t = tasks.get(localId);
|
|
547
|
+
if (!t || isTerminal(t) || !t.remoteId) return t;
|
|
548
|
+
const downloading = t.status === 'download-pending';
|
|
549
|
+
if (Date.now() >= (downloading ? t.downloadDeadline ?? t.createdAt + VIDEO_GIVE_UP_MS : t.createdAt + VIDEO_GIVE_UP_MS)) {
|
|
550
|
+
const expired = putTask({ ...t, status: downloading ? 'download-failed' : 'expired', error: downloading ? 'video completed; download recovery deadline exceeded; do not resubmit' : 'gave up waiting after 6 hours', finishedAt: Date.now() });
|
|
551
|
+
settleJob(expired);
|
|
552
|
+
return expired;
|
|
553
|
+
}
|
|
554
|
+
const bound = captureTask(t);
|
|
555
|
+
const root = gatewayRoot(bound.connection.baseURL);
|
|
556
|
+
const apiKey = await bound.requireApiKey();
|
|
557
|
+
ensureActive();
|
|
558
|
+
// Re-read result URLs on recovery: signed links may expire; never persist the raw status document.
|
|
559
|
+
const r = await fetchJson(`${root}/v1/videos/${encodeURIComponent(t.remoteId)}`, { apiKey, headers: bound.connection.headers, timeoutMs: 30_000, signal: lifetime.signal });
|
|
560
|
+
ensureActive();
|
|
561
|
+
const status = downloading ? 'completed' : normalizeStatus(r.status);
|
|
562
|
+
const progress = r.progress ?? t.progress ?? null;
|
|
563
|
+
let next = { ...t, status, progress, attempts: (t.attempts ?? 0) + 1, lastPollAt: Date.now(), lastPollError: null };
|
|
564
|
+
jobWaiters.get(localId)?.handle.updateProgress(progress != null ? `${status} ${progress}%` : status);
|
|
565
|
+
if (status === 'failed') {
|
|
566
|
+
next = putTask({ ...next, error: r.error?.message ?? r.fail_reason ?? r.message ?? 'the vendor reported a failure', finishedAt: Date.now() });
|
|
567
|
+
settleJob(next);
|
|
568
|
+
return next;
|
|
569
|
+
}
|
|
570
|
+
if (status === 'completed') {
|
|
571
|
+
next = putTask({ ...next, status: 'download-pending', statusDoc: null, downloadDeadline: t.downloadDeadline ?? Date.now() + VIDEO_GIVE_UP_MS });
|
|
572
|
+
next = await saveVideo(next, r, apiKey, root, bound.connection.headers);
|
|
573
|
+
putTask(next);
|
|
574
|
+
if (isTerminal(next)) settleJob(next);
|
|
575
|
+
return next;
|
|
576
|
+
}
|
|
577
|
+
return putTask(next);
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
async function saveVideo(task, statusDoc, apiKey, root, headers) {
|
|
581
|
+
const attachments = ctx.get('attachments');
|
|
582
|
+
const candidates = [
|
|
583
|
+
`${root}/v1/videos/${encodeURIComponent(task.remoteId)}/content`,
|
|
584
|
+
statusDoc.url, statusDoc.video_url, statusDoc.output?.url, statusDoc.data?.url,
|
|
585
|
+
].filter(Boolean);
|
|
586
|
+
let lastError;
|
|
587
|
+
for (const url of candidates) {
|
|
588
|
+
try {
|
|
589
|
+
const { bytes, contentType } = await downloadBytes(url, { apiKey: sameGateway(url, root) ? apiKey : undefined, headers: sameGateway(url, root) ? headers : {}, signal: lifetime.signal, timeoutMs: Math.max(1, Math.min(10 * 60 * 1000, task.downloadDeadline - Date.now())) });
|
|
590
|
+
ensureActive();
|
|
591
|
+
if (bytes.byteLength < 1024) throw new GatewayError('downloaded video is unexpectedly small', 'BAD_RESPONSE');
|
|
592
|
+
const ext = extOfVideo(contentType);
|
|
593
|
+
const file = await attachments.saveFile({ data: bytes, name: `newapi-${task.model}-${task.localId}.${ext}` });
|
|
594
|
+
ensureActive();
|
|
595
|
+
const hostPath = attachments.fileHostPath?.(file);
|
|
596
|
+
history.add({ type: 'video', at: Date.now(), model: task.model, upstreamModel: task.upstreamModel ?? task.model, prompt: task.prompt, taskId: task.localId, file });
|
|
597
|
+
return { ...task, status: 'completed', error: null, statusDoc: null, file, hostPath: hostPath ?? null, finishedAt: Date.now() };
|
|
598
|
+
} catch (error) {
|
|
599
|
+
ensureActive();
|
|
600
|
+
lastError = error;
|
|
601
|
+
}
|
|
602
|
+
}
|
|
603
|
+
return { ...task, status: 'download-pending', error: `video finished but could not be downloaded: ${lastError?.message ?? 'no URL'}; retrying GET only, do not resubmit` };
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
// Resume tasks that were still running when DSH last stopped.
|
|
607
|
+
for (const t of tasks.all()) {
|
|
608
|
+
if (t.status === 'submitting') putTask({ ...t, status: 'submit-unknown', error: 'DSH stopped while submitting; check the New API task log', finishedAt: Date.now() });
|
|
609
|
+
else if (!isTerminal(t) && t.remoteId) schedulePoll(t.localId, 3_000);
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
// --------------------------------------------------------------- web API
|
|
613
|
+
|
|
614
|
+
async function serveAttachment(res, kind, id) {
|
|
615
|
+
const attachments = ctx.get('attachments');
|
|
616
|
+
const items = history.items;
|
|
617
|
+
const ref = kind === 'image'
|
|
618
|
+
? items.flatMap((i) => i.images ?? []).find((r) => r.attachmentId === id)
|
|
619
|
+
: items.map((i) => i.file).filter(Boolean).find((r) => r.attachmentId === id)
|
|
620
|
+
?? tasks.all().map((t) => t.file).filter(Boolean).find((r) => r.attachmentId === id);
|
|
621
|
+
if (!ref) {
|
|
622
|
+
res.statusCode = 404;
|
|
623
|
+
res.end('not found');
|
|
624
|
+
return;
|
|
625
|
+
}
|
|
626
|
+
const filePath = kind === 'image' ? attachments.imageHostPath(ref) : attachments.fileHostPath(ref);
|
|
627
|
+
if (!filePath || !fs.existsSync(filePath)) {
|
|
628
|
+
res.statusCode = 404;
|
|
629
|
+
res.end('not found');
|
|
630
|
+
return;
|
|
631
|
+
}
|
|
632
|
+
const type = kind === 'image' ? ref.mediaType : (/\.webm$/i.test(ref.name) ? 'video/webm' : /\.mov$/i.test(ref.name) ? 'video/quicktime' : 'video/mp4');
|
|
633
|
+
res.statusCode = 200;
|
|
634
|
+
res.setHeader('content-type', type);
|
|
635
|
+
res.setHeader('cache-control', 'private, max-age=86400');
|
|
636
|
+
const stream = fs.createReadStream(filePath);
|
|
637
|
+
stream.on('error', () => {
|
|
638
|
+
if (res.headersSent) res.destroy();
|
|
639
|
+
else { res.statusCode = 500; res.end('media file unavailable'); }
|
|
640
|
+
});
|
|
641
|
+
res.on('close', () => stream.destroy());
|
|
642
|
+
stream.pipe(res);
|
|
643
|
+
}
|
|
644
|
+
|
|
645
|
+
return {
|
|
646
|
+
/** Billed settings-page test: one short video task on a fixed channel, tracked like any other. */
|
|
647
|
+
async submitTestVideo(channelId, model, captured) {
|
|
648
|
+
const value = await submitVideo(
|
|
649
|
+
{ channelId, model, prompt: 'A red ball slowly rolls across a white table.', seconds: 4 },
|
|
650
|
+
{ signal: AbortSignal.timeout(VIDEO_SUBMIT_TIMEOUT_MS + 10_000), callId: `settings-test-${Date.now()}` },
|
|
651
|
+
captured,
|
|
652
|
+
);
|
|
653
|
+
return { taskId: value.taskId };
|
|
654
|
+
},
|
|
655
|
+
status() {
|
|
656
|
+
return {
|
|
657
|
+
tasks: tasks.all().sort((a, b) => b.createdAt - a.createdAt).slice(0, 20).map(publicTask),
|
|
658
|
+
history: history.items.slice(0, 60).map((i) => ({
|
|
659
|
+
type: i.type,
|
|
660
|
+
at: i.at,
|
|
661
|
+
model: i.model,
|
|
662
|
+
upstreamModel: i.upstreamModel ?? i.model,
|
|
663
|
+
prompt: i.prompt,
|
|
664
|
+
taskId: i.taskId ?? null,
|
|
665
|
+
images: (i.images ?? []).map((r) => ({ id: r.attachmentId, width: r.width, height: r.height })),
|
|
666
|
+
file: i.file ? { id: i.file.attachmentId, name: i.file.name, bytes: i.file.bytes } : null,
|
|
667
|
+
})),
|
|
668
|
+
};
|
|
669
|
+
},
|
|
670
|
+
async handle(req, res, url) {
|
|
671
|
+
const m = url.pathname.match(/^\/newapi-provider\/api\/media\/(image|video)\/([A-Za-z0-9_.-]+)$/);
|
|
672
|
+
if (req.method === 'GET' && m) {
|
|
673
|
+
await serveAttachment(res, m[1], m[2]);
|
|
674
|
+
return true;
|
|
675
|
+
}
|
|
676
|
+
return false;
|
|
677
|
+
},
|
|
678
|
+
};
|
|
679
|
+
}
|