pi-meta-oauth 0.3.0 → 0.4.1
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 +7 -3
- package/extensions/media.ts +508 -268
- package/extensions/meta.ts +1 -1
- package/extensions/voice/windows-audio.cs +281 -0
- package/extensions/voice/windows-audio.ps1 +195 -0
- package/extensions/voice.ts +153 -12
- package/package.json +3 -2
package/extensions/media.ts
CHANGED
|
@@ -1,9 +1,15 @@
|
|
|
1
|
+
import type { Usage } from "@earendil-works/pi-ai";
|
|
1
2
|
import type {
|
|
2
3
|
ExtensionAPI,
|
|
3
4
|
ExtensionContext,
|
|
4
5
|
} from "@earendil-works/pi-coding-agent";
|
|
5
6
|
import { Type } from "typebox";
|
|
6
|
-
import {
|
|
7
|
+
import {
|
|
8
|
+
createMetaProviderConfig,
|
|
9
|
+
META_FILES_URL,
|
|
10
|
+
META_API_BASE_URL,
|
|
11
|
+
META_PROVIDER_ID,
|
|
12
|
+
} from "./meta.ts";
|
|
7
13
|
import { existsSync, statSync, readFileSync } from "node:fs";
|
|
8
14
|
import { basename, extname, resolve } from "node:path";
|
|
9
15
|
import { readFile } from "node:fs/promises";
|
|
@@ -11,16 +17,23 @@ import { readFile } from "node:fs/promises";
|
|
|
11
17
|
// ---------------------------------------------------------------------------
|
|
12
18
|
// Meta Files API + Responses API
|
|
13
19
|
// Docs: https://dev.meta.ai/docs/file-handling , /video-understanding
|
|
14
|
-
// - Inline file_data / file_url / video_url /
|
|
20
|
+
// - Inline file_data / file_url / video_url / input_audio : 50 MB limit
|
|
15
21
|
// - Files API POST /v1/files purpose=user_data : 1 GiB, 100 GiB/team storage, no expiry by default
|
|
22
|
+
// - Uploaded media uses input_file {file_id}; inline/public video uses
|
|
23
|
+
// input_video {video_url}; inline audio uses input_audio {data,format}.
|
|
16
24
|
// - Video: video/mp4 only, reads frames + embedded audio together
|
|
17
|
-
// - Audio: audio/mpeg (.mp3), audio/wav (.wav)
|
|
25
|
+
// - Audio: audio/mpeg (.mp3), audio/wav (.wav)
|
|
18
26
|
// - Image: image/png, image/jpeg, image/gif, image/webp, image/x-icon, up to 50/request
|
|
19
27
|
// - PDF: application/pdf -> text first 100p + images first 50p (counts to 50-image budget)
|
|
20
28
|
// ---------------------------------------------------------------------------
|
|
21
29
|
|
|
22
30
|
const INLINE_LIMIT_BYTES = 50_000_000;
|
|
23
31
|
const FILES_API_LIMIT_BYTES = 1_073_741_824;
|
|
32
|
+
// Meta Responses `store=true` (default) has ~20 MB persistence limit even when
|
|
33
|
+
// inline limit is 50 MB — a 24 MB base64 payload triggers HTTP 413
|
|
34
|
+
// "payload_too_large … with `store=true`". Keep inline only for small files
|
|
35
|
+
// and force Files API or `store:false` above this threshold.
|
|
36
|
+
const STORE_SAFE_INLINE_BYTES = 15_000_000;
|
|
24
37
|
|
|
25
38
|
type SupportedMime =
|
|
26
39
|
| "video/mp4"
|
|
@@ -81,10 +94,85 @@ function isImageMime(m: string | undefined): boolean {
|
|
|
81
94
|
return !!m && m.startsWith("image/");
|
|
82
95
|
}
|
|
83
96
|
|
|
84
|
-
function
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
97
|
+
function filenameForMime(
|
|
98
|
+
mime: string | undefined,
|
|
99
|
+
fallback = "file.bin",
|
|
100
|
+
): string {
|
|
101
|
+
switch (mime) {
|
|
102
|
+
case "video/mp4":
|
|
103
|
+
return "video.mp4";
|
|
104
|
+
case "audio/mpeg":
|
|
105
|
+
case "audio/mp3":
|
|
106
|
+
return "audio.mp3";
|
|
107
|
+
case "audio/wav":
|
|
108
|
+
case "audio/x-wav":
|
|
109
|
+
return "audio.wav";
|
|
110
|
+
case "application/pdf":
|
|
111
|
+
return "document.pdf";
|
|
112
|
+
default:
|
|
113
|
+
return fallback;
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function audioFormatForMime(mime: string | undefined): "wav" | "mp3" {
|
|
118
|
+
return mime === "audio/wav" || mime === "audio/x-wav" ? "wav" : "mp3";
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** Build a generic Meta Responses input_file block. */
|
|
122
|
+
function inputFileFromSource(
|
|
123
|
+
source: string,
|
|
124
|
+
filename?: string,
|
|
125
|
+
): ResponsesContentBlock {
|
|
126
|
+
if (source.startsWith("https://")) {
|
|
127
|
+
return { type: "input_file", file_url: source };
|
|
128
|
+
}
|
|
129
|
+
if (source.startsWith("data:")) {
|
|
130
|
+
const mime = mimeForDataUrl(source);
|
|
131
|
+
return {
|
|
132
|
+
type: "input_file",
|
|
133
|
+
file_data: source,
|
|
134
|
+
filename: filename ?? filenameForMime(mime),
|
|
135
|
+
};
|
|
136
|
+
}
|
|
137
|
+
// Bare file_id (file-...)
|
|
138
|
+
return { type: "input_file", file_id: source };
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function mimeForSource(source: string): string | undefined {
|
|
142
|
+
if (source.startsWith("data:")) return mimeForDataUrl(source);
|
|
143
|
+
if (source.startsWith("https://")) {
|
|
144
|
+
try {
|
|
145
|
+
return mimeForPath(new URL(source).pathname);
|
|
146
|
+
} catch {
|
|
147
|
+
return undefined;
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
return mimeForPath(source);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/** Build the typed content block required by Meta for each media kind. */
|
|
154
|
+
export function mediaInputFromSource(
|
|
155
|
+
source: string,
|
|
156
|
+
filename?: string,
|
|
157
|
+
expectedMime?: string,
|
|
158
|
+
): ResponsesContentBlock {
|
|
159
|
+
if (!source.startsWith("data:") && !source.startsWith("https://")) {
|
|
160
|
+
return { type: "input_file", file_id: source };
|
|
161
|
+
}
|
|
162
|
+
const mime = expectedMime ?? mimeForSource(source);
|
|
163
|
+
if (isVideoMime(mime)) {
|
|
164
|
+
return { type: "input_video", video_url: source };
|
|
165
|
+
}
|
|
166
|
+
if (isAudioMime(mime) && source.startsWith("data:")) {
|
|
167
|
+
return {
|
|
168
|
+
type: "input_audio",
|
|
169
|
+
input_audio: {
|
|
170
|
+
data: base64FromDataUrl(source),
|
|
171
|
+
format: audioFormatForMime(mime),
|
|
172
|
+
},
|
|
173
|
+
};
|
|
174
|
+
}
|
|
175
|
+
return inputFileFromSource(source, filename);
|
|
88
176
|
}
|
|
89
177
|
|
|
90
178
|
function base64FromDataUrl(dataUrl: string): string {
|
|
@@ -92,6 +180,17 @@ function base64FromDataUrl(dataUrl: string): string {
|
|
|
92
180
|
return comma === -1 ? "" : dataUrl.slice(comma + 1);
|
|
93
181
|
}
|
|
94
182
|
|
|
183
|
+
function base64ByteLength(base64: string): number {
|
|
184
|
+
const normalized = base64.replace(/\s/g, "");
|
|
185
|
+
if (!normalized) return 0;
|
|
186
|
+
const padding = normalized.endsWith("==")
|
|
187
|
+
? 2
|
|
188
|
+
: normalized.endsWith("=")
|
|
189
|
+
? 1
|
|
190
|
+
: 0;
|
|
191
|
+
return Math.max(0, Math.floor((normalized.length * 3) / 4) - padding);
|
|
192
|
+
}
|
|
193
|
+
|
|
95
194
|
function safeNotify(
|
|
96
195
|
ctx:
|
|
97
196
|
| ExtensionContext
|
|
@@ -227,28 +326,20 @@ async function uploadMetaFile(
|
|
|
227
326
|
};
|
|
228
327
|
}
|
|
229
328
|
|
|
230
|
-
const ALLOWED_META_HOSTS = ["api.meta.ai"] as const;
|
|
231
329
|
const ALLOWED_PURPOSES = ["user_data", "batch"] as const;
|
|
232
330
|
|
|
233
331
|
async function listMetaFiles(
|
|
234
332
|
apiKey: string,
|
|
235
333
|
purpose = "user_data",
|
|
236
334
|
): Promise<unknown> {
|
|
237
|
-
if (
|
|
335
|
+
if (!(ALLOWED_PURPOSES as readonly string[]).includes(purpose)) {
|
|
238
336
|
throw new Error(`Invalid purpose: ${purpose}`);
|
|
239
337
|
}
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
}
|
|
246
|
-
if (!(ALLOWED_META_HOSTS as readonly string[]).includes(url.hostname))
|
|
247
|
-
throw new Error(`Unexpected Files API host: ${url.hostname}`);
|
|
248
|
-
if (purpose) url.searchParams.set("purpose", purpose);
|
|
249
|
-
const res = await fetch(url.toString(), {
|
|
250
|
-
headers: { Authorization: `Bearer ${apiKey}` },
|
|
251
|
-
});
|
|
338
|
+
const options = { headers: { Authorization: `Bearer ${apiKey}` } };
|
|
339
|
+
const res =
|
|
340
|
+
purpose === "batch"
|
|
341
|
+
? await fetch("https://api.meta.ai/v1/files?purpose=batch", options)
|
|
342
|
+
: await fetch("https://api.meta.ai/v1/files?purpose=user_data", options);
|
|
252
343
|
const text = await res.text();
|
|
253
344
|
if (!res.ok)
|
|
254
345
|
throw new Error(
|
|
@@ -300,30 +391,150 @@ interface ResponsesContentBlock {
|
|
|
300
391
|
[key: string]: unknown;
|
|
301
392
|
}
|
|
302
393
|
|
|
394
|
+
function record(value: unknown): Record<string, unknown> | undefined {
|
|
395
|
+
return value !== null && typeof value === "object"
|
|
396
|
+
? (value as Record<string, unknown>)
|
|
397
|
+
: undefined;
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
function tokenCount(...values: unknown[]): number | undefined {
|
|
401
|
+
for (const value of values) {
|
|
402
|
+
const numeric =
|
|
403
|
+
typeof value === "number"
|
|
404
|
+
? value
|
|
405
|
+
: typeof value === "string" && value.trim()
|
|
406
|
+
? Number(value)
|
|
407
|
+
: Number.NaN;
|
|
408
|
+
if (Number.isFinite(numeric)) return Math.max(0, Math.trunc(numeric));
|
|
409
|
+
}
|
|
410
|
+
return undefined;
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
/** Convert a Meta Responses API usage block into Pi's nested-tool usage shape. */
|
|
414
|
+
export function extractMetaResponseUsage(
|
|
415
|
+
response: unknown,
|
|
416
|
+
modelId = "muse-spark-1.2",
|
|
417
|
+
): Usage | undefined {
|
|
418
|
+
const usage = record(record(response)?.usage);
|
|
419
|
+
if (!usage) return undefined;
|
|
420
|
+
|
|
421
|
+
const inputDetails = record(
|
|
422
|
+
usage.input_tokens_details ?? usage.inputTokensDetails,
|
|
423
|
+
);
|
|
424
|
+
const outputDetails = record(
|
|
425
|
+
usage.output_tokens_details ?? usage.outputTokensDetails,
|
|
426
|
+
);
|
|
427
|
+
const inputTotal = tokenCount(
|
|
428
|
+
usage.input_tokens,
|
|
429
|
+
usage.inputTokens,
|
|
430
|
+
usage.prompt_tokens,
|
|
431
|
+
usage.promptTokens,
|
|
432
|
+
usage.input,
|
|
433
|
+
);
|
|
434
|
+
const output = tokenCount(
|
|
435
|
+
usage.output_tokens,
|
|
436
|
+
usage.outputTokens,
|
|
437
|
+
usage.completion_tokens,
|
|
438
|
+
usage.completionTokens,
|
|
439
|
+
usage.output,
|
|
440
|
+
);
|
|
441
|
+
if (inputTotal === undefined && output === undefined) return undefined;
|
|
442
|
+
|
|
443
|
+
const reportedInput = inputTotal ?? 0;
|
|
444
|
+
const cacheRead = Math.min(
|
|
445
|
+
reportedInput,
|
|
446
|
+
tokenCount(inputDetails?.cached_tokens, inputDetails?.cachedTokens) ?? 0,
|
|
447
|
+
);
|
|
448
|
+
const cacheWrite = Math.min(
|
|
449
|
+
reportedInput - cacheRead,
|
|
450
|
+
tokenCount(
|
|
451
|
+
inputDetails?.cache_write_tokens,
|
|
452
|
+
inputDetails?.cacheWriteTokens,
|
|
453
|
+
) ?? 0,
|
|
454
|
+
);
|
|
455
|
+
const uncachedInput = Math.max(0, reportedInput - cacheRead - cacheWrite);
|
|
456
|
+
const outputTokens = output ?? 0;
|
|
457
|
+
const reasoning = Math.min(
|
|
458
|
+
outputTokens,
|
|
459
|
+
tokenCount(
|
|
460
|
+
outputDetails?.reasoning_tokens,
|
|
461
|
+
outputDetails?.reasoningTokens,
|
|
462
|
+
) ?? 0,
|
|
463
|
+
);
|
|
464
|
+
const totalTokens =
|
|
465
|
+
tokenCount(usage.total_tokens, usage.totalTokens) ??
|
|
466
|
+
uncachedInput + cacheRead + cacheWrite + outputTokens;
|
|
467
|
+
const model = createMetaProviderConfig().models?.find(
|
|
468
|
+
(candidate) => candidate.id === modelId,
|
|
469
|
+
);
|
|
470
|
+
const rates = model?.cost;
|
|
471
|
+
const cost = {
|
|
472
|
+
input: (uncachedInput * (rates?.input ?? 0)) / 1_000_000,
|
|
473
|
+
output: (outputTokens * (rates?.output ?? 0)) / 1_000_000,
|
|
474
|
+
cacheRead: (cacheRead * (rates?.cacheRead ?? 0)) / 1_000_000,
|
|
475
|
+
cacheWrite: (cacheWrite * (rates?.cacheWrite ?? 0)) / 1_000_000,
|
|
476
|
+
total: 0,
|
|
477
|
+
};
|
|
478
|
+
cost.total = cost.input + cost.output + cost.cacheRead + cost.cacheWrite;
|
|
479
|
+
|
|
480
|
+
return {
|
|
481
|
+
input: uncachedInput,
|
|
482
|
+
output: outputTokens,
|
|
483
|
+
cacheRead,
|
|
484
|
+
cacheWrite,
|
|
485
|
+
reasoning,
|
|
486
|
+
totalTokens,
|
|
487
|
+
cost,
|
|
488
|
+
};
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
export function formatMetaResponseUsage(usage: Usage): string {
|
|
492
|
+
const parts = [`${usage.input.toLocaleString()} input`];
|
|
493
|
+
if (usage.cacheRead)
|
|
494
|
+
parts.push(`${usage.cacheRead.toLocaleString()} cache read`);
|
|
495
|
+
if (usage.cacheWrite)
|
|
496
|
+
parts.push(`${usage.cacheWrite.toLocaleString()} cache write`);
|
|
497
|
+
parts.push(`${usage.output.toLocaleString()} output`);
|
|
498
|
+
if (usage.reasoning)
|
|
499
|
+
parts.push(`${usage.reasoning.toLocaleString()} reasoning`);
|
|
500
|
+
return `Video token usage: ${usage.totalTokens.toLocaleString()} total (${parts.join(", ")})`;
|
|
501
|
+
}
|
|
502
|
+
|
|
303
503
|
// Helper to extract output_text from Responses API response (covers streamed and non-streamed shapes)
|
|
304
|
-
function extractResponsesText(json: unknown): string {
|
|
504
|
+
export function extractResponsesText(json: unknown): string {
|
|
305
505
|
if (!json || typeof json !== "object") return "";
|
|
306
506
|
const j = json as Record<string, unknown>;
|
|
307
|
-
//
|
|
308
|
-
if (typeof j.output_text === "string"
|
|
309
|
-
|
|
507
|
+
// Some Meta responses include an empty output_text alongside populated output blocks.
|
|
508
|
+
if (typeof j.output_text === "string" && j.output_text.trim())
|
|
509
|
+
return j.output_text;
|
|
310
510
|
const output = j.output as unknown;
|
|
311
511
|
if (Array.isArray(output)) {
|
|
312
512
|
const texts: string[] = [];
|
|
313
513
|
for (const item of output as Record<string, unknown>[]) {
|
|
314
|
-
if (!item || typeof item !== "object")
|
|
315
|
-
|
|
514
|
+
if (!item || typeof item !== "object" || item.type !== "message")
|
|
515
|
+
continue;
|
|
516
|
+
if (typeof item.content === "string" && item.content.trim())
|
|
517
|
+
texts.push(item.content);
|
|
518
|
+
if (Array.isArray(item.content)) {
|
|
316
519
|
for (const c of item.content as Record<string, unknown>[]) {
|
|
317
|
-
if (
|
|
520
|
+
if (
|
|
521
|
+
(c.type === "output_text" || c.type === "text") &&
|
|
522
|
+
typeof c.text === "string" &&
|
|
523
|
+
c.text.trim()
|
|
524
|
+
)
|
|
318
525
|
texts.push(c.text);
|
|
319
|
-
if (
|
|
526
|
+
if (
|
|
527
|
+
c.type === "refusal" &&
|
|
528
|
+
typeof c.refusal === "string" &&
|
|
529
|
+
c.refusal.trim()
|
|
530
|
+
)
|
|
320
531
|
texts.push(c.refusal);
|
|
321
532
|
}
|
|
322
533
|
}
|
|
323
534
|
}
|
|
324
535
|
if (texts.length) return texts.join("\n\n");
|
|
325
536
|
}
|
|
326
|
-
if (typeof j.text === "string") return j.text;
|
|
537
|
+
if (typeof j.text === "string" && j.text.trim()) return j.text;
|
|
327
538
|
return JSON.stringify(json, null, 2).slice(0, 8000);
|
|
328
539
|
}
|
|
329
540
|
|
|
@@ -332,17 +543,23 @@ async function callMetaResponses(
|
|
|
332
543
|
payload: Record<string, unknown>,
|
|
333
544
|
signal?: AbortSignal,
|
|
334
545
|
): Promise<{ text: string; raw: unknown }> {
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
546
|
+
// Force store:false for media payloads to avoid the ~20 MB
|
|
547
|
+
// persistence limit (413 payload_too_large with store=true). Files API
|
|
548
|
+
// is the correct path for large media per /docs/file-handling.
|
|
549
|
+
payload.store = false;
|
|
550
|
+
const doFetch = async (body: Record<string, unknown>) =>
|
|
551
|
+
await fetch(responsesUrl(), {
|
|
552
|
+
method: "POST",
|
|
553
|
+
headers: {
|
|
554
|
+
Authorization: `Bearer ${apiKey}`,
|
|
555
|
+
"Content-Type": "application/json",
|
|
556
|
+
"x-api-version": "1.0.0",
|
|
557
|
+
},
|
|
558
|
+
body: JSON.stringify(body),
|
|
559
|
+
signal,
|
|
560
|
+
});
|
|
561
|
+
let res = await doFetch(payload);
|
|
562
|
+
let text = await res.text();
|
|
346
563
|
let json: unknown;
|
|
347
564
|
try {
|
|
348
565
|
json = text ? (JSON.parse(text) as unknown) : {};
|
|
@@ -360,6 +577,22 @@ async function callMetaResponses(
|
|
|
360
577
|
"message" in (json as Record<string, unknown>)
|
|
361
578
|
? String((json as Record<string, unknown>).message).slice(0, 1000)
|
|
362
579
|
: text.slice(0, 1000)) || res.statusText;
|
|
580
|
+
// Auto-retry once with store:false if the error is the ~20 MB
|
|
581
|
+
// persistence limit (413 with store=true) and we haven't already set it.
|
|
582
|
+
const isStoreLimit =
|
|
583
|
+
res.status === 413 &&
|
|
584
|
+
String(detail).includes("store=true") &&
|
|
585
|
+
payload.store !== false;
|
|
586
|
+
if (isStoreLimit) {
|
|
587
|
+
res = await doFetch({ ...payload, store: false });
|
|
588
|
+
text = await res.text();
|
|
589
|
+
try {
|
|
590
|
+
json = text ? (JSON.parse(text) as unknown) : {};
|
|
591
|
+
} catch {
|
|
592
|
+
json = text;
|
|
593
|
+
}
|
|
594
|
+
if (res.ok) return { text: extractResponsesText(json), raw: json };
|
|
595
|
+
}
|
|
363
596
|
throw new Error(`Meta Responses failed (HTTP ${res.status}): ${detail}`);
|
|
364
597
|
}
|
|
365
598
|
return { text: extractResponsesText(json), raw: json };
|
|
@@ -386,10 +619,9 @@ function dataUrlForFile(path: string): {
|
|
|
386
619
|
// pi-ai's UserMessage is still string | (TextContent | ImageContent)[].
|
|
387
620
|
// We smuggle video/audio/pdf as ImageContent (data:video/..., data:audio/..., data:application/pdf...)
|
|
388
621
|
// via the input handler or via read tool results; this hook rewrites the
|
|
389
|
-
// resulting {type:"input_image", image_url:"data:video/..."} into the
|
|
390
|
-
//
|
|
391
|
-
//
|
|
392
|
-
// Also handles https://... video URLs and large inline payloads by uploading via Files API.
|
|
622
|
+
// resulting {type:"input_image", image_url:"data:video/..."} into the typed
|
|
623
|
+
// input_video/input_audio/input_file block required by Meta before the request is sent.
|
|
624
|
+
// Also handles https://... media URLs and large inline payloads via Files API.
|
|
393
625
|
// Enable/disable via PI_META_NATIVE_ATTACHMENTS env (default: on).
|
|
394
626
|
// ---------------------------------------------------------------------------
|
|
395
627
|
|
|
@@ -430,38 +662,36 @@ function rewriteBlock(
|
|
|
430
662
|
if (isHttpsImage || isImageMime(mime)) return null; // keep as image
|
|
431
663
|
|
|
432
664
|
if (isVideoMime(mime) || isHttpsVideo) {
|
|
433
|
-
|
|
434
|
-
|
|
665
|
+
return mediaInputFromSource(url, "video.mp4", "video/mp4") as Record<
|
|
666
|
+
string,
|
|
667
|
+
unknown
|
|
668
|
+
>;
|
|
435
669
|
}
|
|
436
670
|
if (isAudioMime(mime) || isHttpsAudio) {
|
|
437
|
-
const data = url.startsWith("data:") ? base64FromDataUrl(url) : url;
|
|
438
671
|
const effectiveMime =
|
|
439
672
|
typeof mime === "string"
|
|
440
673
|
? mime
|
|
441
674
|
: lowerUrl.endsWith(".wav")
|
|
442
675
|
? "audio/wav"
|
|
443
676
|
: "audio/mpeg";
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
// For https audio URL, use input_audio with audio_url-like fallback? Docs: input_audio can be file_id or audio_url data URI or inline.
|
|
450
|
-
// For public https audio, the cleanest is input_audio with audio_url is not documented for responses; use file_url via input_file if needed.
|
|
451
|
-
// Fallback: treat as input_file file_url
|
|
452
|
-
return { type: "input_file", file_url: url };
|
|
677
|
+
return mediaInputFromSource(
|
|
678
|
+
url,
|
|
679
|
+
filenameForMime(effectiveMime, "audio.bin"),
|
|
680
|
+
effectiveMime,
|
|
681
|
+
) as Record<string, unknown>;
|
|
453
682
|
}
|
|
454
683
|
if (isPdfMime(mime) || isHttpsPdf) {
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
684
|
+
return mediaInputFromSource(
|
|
685
|
+
url,
|
|
686
|
+
"document.pdf",
|
|
687
|
+
"application/pdf",
|
|
688
|
+
) as Record<string, unknown>;
|
|
460
689
|
}
|
|
461
690
|
return null;
|
|
462
691
|
}
|
|
463
692
|
|
|
464
|
-
|
|
693
|
+
/** Rewrite smuggled media image_url blocks into Meta-supported typed blocks. */
|
|
694
|
+
export function rewriteResponsesPayload(payload: unknown): {
|
|
465
695
|
rewritten: boolean;
|
|
466
696
|
payload: unknown;
|
|
467
697
|
} {
|
|
@@ -530,12 +760,45 @@ function rewriteResponsesPayload(payload: unknown): {
|
|
|
530
760
|
return { rewritten: true, payload: { ...p, input: newInput } };
|
|
531
761
|
}
|
|
532
762
|
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
763
|
+
async function uploadInlineMedia(
|
|
764
|
+
apiKey: string,
|
|
765
|
+
base64: string,
|
|
766
|
+
mime: string,
|
|
767
|
+
filename: string,
|
|
768
|
+
): Promise<string> {
|
|
769
|
+
const blob = new Blob([Buffer.from(base64, "base64")], { type: mime });
|
|
770
|
+
const form = new FormData();
|
|
771
|
+
form.append("file", blob, filename);
|
|
772
|
+
form.append("purpose", "user_data");
|
|
773
|
+
const res = await fetch(META_FILES_URL, {
|
|
774
|
+
method: "POST",
|
|
775
|
+
headers: { Authorization: `Bearer ${apiKey}` },
|
|
776
|
+
body: form,
|
|
777
|
+
});
|
|
778
|
+
const text = await res.text();
|
|
779
|
+
if (!res.ok) {
|
|
780
|
+
throw new Error(
|
|
781
|
+
`Files upload for large inline media failed: ${text.slice(0, 500)}`,
|
|
782
|
+
);
|
|
783
|
+
}
|
|
784
|
+
let json: Record<string, unknown>;
|
|
785
|
+
try {
|
|
786
|
+
json = JSON.parse(text) as Record<string, unknown>;
|
|
787
|
+
} catch (error) {
|
|
788
|
+
throw new Error(`Files upload returned invalid JSON: ${String(error)}`);
|
|
789
|
+
}
|
|
790
|
+
if (typeof json.id !== "string" || !json.id) {
|
|
791
|
+
throw new Error(`Files upload returned no id: ${text.slice(0, 500)}`);
|
|
792
|
+
}
|
|
793
|
+
return json.id;
|
|
794
|
+
}
|
|
795
|
+
|
|
796
|
+
// Promote inline media above the store-safe threshold to the Files API. Typed
|
|
797
|
+
// input_video/input_audio blocks stay inline below the threshold.
|
|
798
|
+
export async function maybeUploadLargeInlineBlocks(
|
|
537
799
|
payload: unknown,
|
|
538
800
|
apiKey: string | undefined,
|
|
801
|
+
uploadThresholdBytes = STORE_SAFE_INLINE_BYTES,
|
|
539
802
|
): Promise<{ payload: unknown; uploaded: number }> {
|
|
540
803
|
if (!apiKey) return { payload, uploaded: 0 };
|
|
541
804
|
if (!payload || typeof payload !== "object") return { payload, uploaded: 0 };
|
|
@@ -543,159 +806,84 @@ async function maybeUploadLargeInlineBlocks(
|
|
|
543
806
|
const input = p.input;
|
|
544
807
|
if (!Array.isArray(input)) return { payload, uploaded: 0 };
|
|
545
808
|
let uploaded = 0;
|
|
546
|
-
|
|
809
|
+
let needsStoreFalse = false;
|
|
547
810
|
const newInput: unknown[] = [];
|
|
811
|
+
|
|
548
812
|
for (const entry of input as Record<string, unknown>[]) {
|
|
549
|
-
if (
|
|
550
|
-
!entry ||
|
|
551
|
-
typeof entry !== "object" ||
|
|
552
|
-
!Array.isArray((entry as Record<string, unknown>).content)
|
|
553
|
-
) {
|
|
813
|
+
if (!entry || typeof entry !== "object" || !Array.isArray(entry.content)) {
|
|
554
814
|
newInput.push(entry);
|
|
555
815
|
continue;
|
|
556
816
|
}
|
|
557
|
-
const
|
|
558
|
-
const content = e.content as Record<string, unknown>[];
|
|
817
|
+
const content = entry.content as Record<string, unknown>[];
|
|
559
818
|
const newContent: Record<string, unknown>[] = [];
|
|
560
819
|
for (const block of content) {
|
|
820
|
+
let inline:
|
|
821
|
+
| { base64: string; mime: string; filename: string }
|
|
822
|
+
| undefined;
|
|
561
823
|
if (
|
|
562
824
|
block.type === "input_video" &&
|
|
563
825
|
typeof block.video_url === "string" &&
|
|
564
826
|
block.video_url.startsWith("data:")
|
|
565
827
|
) {
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
body: form,
|
|
583
|
-
});
|
|
584
|
-
const text = await res.text();
|
|
585
|
-
if (!res.ok)
|
|
586
|
-
throw new Error(
|
|
587
|
-
`Files upload for large video failed: ${text.slice(0, 500)}`,
|
|
588
|
-
);
|
|
589
|
-
let j: Record<string, unknown>;
|
|
590
|
-
try {
|
|
591
|
-
j = JSON.parse(text) as Record<string, unknown>;
|
|
592
|
-
} catch (error) {
|
|
593
|
-
throw new Error(
|
|
594
|
-
`Files upload returned invalid JSON: ${String(error)}`,
|
|
595
|
-
);
|
|
596
|
-
}
|
|
597
|
-
const id = typeof j.id === "string" ? j.id : "";
|
|
598
|
-
if (!id)
|
|
599
|
-
throw new Error(`Upload returned no id: ${text.slice(0, 500)}`);
|
|
600
|
-
uploaded++;
|
|
601
|
-
newContent.push({ type: "input_file", file_id: id });
|
|
602
|
-
continue;
|
|
828
|
+
inline = {
|
|
829
|
+
base64: base64FromDataUrl(block.video_url),
|
|
830
|
+
mime: mimeForDataUrl(block.video_url) ?? "video/mp4",
|
|
831
|
+
filename: "video.mp4",
|
|
832
|
+
};
|
|
833
|
+
} else if (block.type === "input_audio") {
|
|
834
|
+
const audio = record(block.input_audio);
|
|
835
|
+
if (typeof audio?.data === "string") {
|
|
836
|
+
const format = audio.format === "wav" ? "wav" : "mp3";
|
|
837
|
+
inline = {
|
|
838
|
+
base64: audio.data.startsWith("data:")
|
|
839
|
+
? base64FromDataUrl(audio.data)
|
|
840
|
+
: audio.data,
|
|
841
|
+
mime: format === "wav" ? "audio/wav" : "audio/mpeg",
|
|
842
|
+
filename: `audio.${format}`,
|
|
843
|
+
};
|
|
603
844
|
}
|
|
604
|
-
}
|
|
605
|
-
if (
|
|
845
|
+
} else if (
|
|
606
846
|
block.type === "input_file" &&
|
|
607
847
|
typeof block.file_data === "string" &&
|
|
608
848
|
block.file_data.startsWith("data:")
|
|
609
849
|
) {
|
|
610
|
-
const
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
blob,
|
|
621
|
-
`file${extname("file." + (mime.split("/")[1] || ""))}`,
|
|
622
|
-
);
|
|
623
|
-
form.append("purpose", "user_data");
|
|
624
|
-
const res = await fetch(META_FILES_URL, {
|
|
625
|
-
method: "POST",
|
|
626
|
-
headers: { Authorization: `Bearer ${apiKey}` },
|
|
627
|
-
body: form,
|
|
628
|
-
});
|
|
629
|
-
const text = await res.text();
|
|
630
|
-
if (!res.ok)
|
|
631
|
-
throw new Error(
|
|
632
|
-
`Files upload for large file failed: ${text.slice(0, 500)}`,
|
|
633
|
-
);
|
|
634
|
-
let j: Record<string, unknown>;
|
|
635
|
-
try {
|
|
636
|
-
j = JSON.parse(text) as Record<string, unknown>;
|
|
637
|
-
} catch (error) {
|
|
638
|
-
throw new Error(
|
|
639
|
-
`Files upload returned invalid JSON: ${String(error)}`,
|
|
640
|
-
);
|
|
641
|
-
}
|
|
642
|
-
const id = typeof j.id === "string" ? j.id : "";
|
|
643
|
-
if (id) {
|
|
644
|
-
uploaded++;
|
|
645
|
-
newContent.push({ type: "input_file", file_id: id });
|
|
646
|
-
continue;
|
|
647
|
-
}
|
|
648
|
-
}
|
|
850
|
+
const mime =
|
|
851
|
+
mimeForDataUrl(block.file_data) ?? "application/octet-stream";
|
|
852
|
+
inline = {
|
|
853
|
+
base64: base64FromDataUrl(block.file_data),
|
|
854
|
+
mime,
|
|
855
|
+
filename:
|
|
856
|
+
typeof block.filename === "string" && block.filename
|
|
857
|
+
? block.filename
|
|
858
|
+
: filenameForMime(mime),
|
|
859
|
+
};
|
|
649
860
|
}
|
|
650
|
-
if (
|
|
651
|
-
block.type === "input_audio" &&
|
|
652
|
-
block.input_audio &&
|
|
653
|
-
typeof (block.input_audio as Record<string, unknown>).data === "string"
|
|
654
|
-
) {
|
|
655
|
-
const data = (block.input_audio as Record<string, unknown>)
|
|
656
|
-
.data as string;
|
|
657
|
-
const bytes = Math.floor((data.length * 3) / 4);
|
|
658
|
-
if (bytes > INLINE_LIMIT_BYTES) {
|
|
659
|
-
const format = (block.input_audio as Record<string, unknown>)
|
|
660
|
-
.format as string;
|
|
661
|
-
const mime = format === "wav" ? "audio/wav" : "audio/mpeg";
|
|
662
|
-
const blob = new Blob([Buffer.from(data, "base64")], { type: mime });
|
|
663
|
-
const form = new FormData();
|
|
664
|
-
form.append("file", blob, `audio.${format}`);
|
|
665
|
-
form.append("purpose", "user_data");
|
|
666
|
-
const res = await fetch(META_FILES_URL, {
|
|
667
|
-
method: "POST",
|
|
668
|
-
headers: { Authorization: `Bearer ${apiKey}` },
|
|
669
|
-
body: form,
|
|
670
|
-
});
|
|
671
|
-
const text = await res.text();
|
|
672
|
-
if (!res.ok)
|
|
673
|
-
throw new Error(
|
|
674
|
-
`Files upload for large audio failed: ${text.slice(0, 500)}`,
|
|
675
|
-
);
|
|
676
|
-
let j: Record<string, unknown>;
|
|
677
|
-
try {
|
|
678
|
-
j = JSON.parse(text) as Record<string, unknown>;
|
|
679
|
-
} catch (error) {
|
|
680
|
-
throw new Error(
|
|
681
|
-
`Files upload returned invalid JSON: ${String(error)}`,
|
|
682
|
-
);
|
|
683
|
-
}
|
|
684
861
|
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
862
|
+
if (inline) {
|
|
863
|
+
needsStoreFalse = true;
|
|
864
|
+
if (base64ByteLength(inline.base64) > uploadThresholdBytes) {
|
|
865
|
+
const id = await uploadInlineMedia(
|
|
866
|
+
apiKey,
|
|
867
|
+
inline.base64,
|
|
868
|
+
inline.mime,
|
|
869
|
+
inline.filename,
|
|
870
|
+
);
|
|
871
|
+
uploaded++;
|
|
872
|
+
newContent.push({ type: "input_file", file_id: id });
|
|
873
|
+
continue;
|
|
691
874
|
}
|
|
692
875
|
}
|
|
693
876
|
newContent.push(block);
|
|
694
877
|
}
|
|
695
|
-
newInput.push({ ...
|
|
878
|
+
newInput.push({ ...entry, content: newContent });
|
|
879
|
+
}
|
|
880
|
+
|
|
881
|
+
if (uploaded === 0) {
|
|
882
|
+
return needsStoreFalse
|
|
883
|
+
? { payload: { ...p, store: false }, uploaded: 0 }
|
|
884
|
+
: { payload, uploaded: 0 };
|
|
696
885
|
}
|
|
697
|
-
|
|
698
|
-
return { payload: { ...p, input: newInput }, uploaded };
|
|
886
|
+
return { payload: { ...p, input: newInput, store: false }, uploaded };
|
|
699
887
|
}
|
|
700
888
|
|
|
701
889
|
// ---------------------------------------------------------------------------
|
|
@@ -705,28 +893,64 @@ async function maybeUploadLargeInlineBlocks(
|
|
|
705
893
|
export default function metaMedia(pi: ExtensionAPI): void {
|
|
706
894
|
// --- Before provider request rewrite (Option B transparent path) ---
|
|
707
895
|
if (shouldEnableNativeRewrite()) {
|
|
708
|
-
pi.on("before_provider_request", async (event) => {
|
|
709
|
-
|
|
896
|
+
pi.on("before_provider_request", async (event, ctx) => {
|
|
897
|
+
if (ctx.model?.provider !== META_PROVIDER_ID) return undefined;
|
|
898
|
+
const original = event.payload as Record<string, unknown>;
|
|
710
899
|
const { rewritten, payload: rewrittenPayload } =
|
|
711
900
|
rewriteResponsesPayload(original);
|
|
712
|
-
|
|
713
|
-
//
|
|
714
|
-
//
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
901
|
+
// Even without rewrite, large inline media in the original payload
|
|
902
|
+
// (e.g. drag-dropped video) can hit the 413 store=true limit —
|
|
903
|
+
// ensure store:false and try Files API upload.
|
|
904
|
+
const payloadToCheck = rewritten ? rewrittenPayload : original;
|
|
905
|
+
const hasMedia = (() => {
|
|
906
|
+
if (!payloadToCheck || typeof payloadToCheck !== "object") return false;
|
|
907
|
+
const input = (payloadToCheck as Record<string, unknown>).input;
|
|
908
|
+
if (!Array.isArray(input)) return false;
|
|
909
|
+
for (const e of input as Record<string, unknown>[]) {
|
|
910
|
+
if (!e || typeof e !== "object" || !Array.isArray(e.content))
|
|
911
|
+
continue;
|
|
912
|
+
for (const b of e.content as Record<string, unknown>[]) {
|
|
913
|
+
if (
|
|
914
|
+
b.type === "input_file" ||
|
|
915
|
+
b.type === "input_image" ||
|
|
916
|
+
b.type === "input_video" ||
|
|
917
|
+
b.type === "input_audio"
|
|
918
|
+
)
|
|
919
|
+
return true;
|
|
920
|
+
}
|
|
921
|
+
}
|
|
922
|
+
return false;
|
|
923
|
+
})();
|
|
924
|
+
if (!rewritten && !hasMedia) return undefined;
|
|
925
|
+
// Resolve the same stored Meta credential used by the active provider. This is
|
|
926
|
+
// essential for @file drag-drop, where the key usually is not in process.env.
|
|
927
|
+
let apiKey =
|
|
718
928
|
process.env.MODEL_API_KEY ?? process.env.META_API_KEY ?? undefined;
|
|
929
|
+
if (!apiKey) {
|
|
930
|
+
try {
|
|
931
|
+
apiKey =
|
|
932
|
+
(await ctx.modelRegistry.getApiKeyForProvider(META_PROVIDER_ID)) ??
|
|
933
|
+
undefined;
|
|
934
|
+
} catch {
|
|
935
|
+
// Fall back to typed inline media with store:false below.
|
|
936
|
+
}
|
|
937
|
+
}
|
|
719
938
|
if (apiKey) {
|
|
720
939
|
try {
|
|
721
940
|
const { payload: uploadedPayload } =
|
|
722
|
-
await maybeUploadLargeInlineBlocks(
|
|
941
|
+
await maybeUploadLargeInlineBlocks(payloadToCheck, apiKey);
|
|
723
942
|
return uploadedPayload as unknown;
|
|
724
943
|
} catch {
|
|
725
|
-
// fall back
|
|
726
|
-
|
|
944
|
+
// fall back without upload — at least force store:false
|
|
945
|
+
const fallback = payloadToCheck as Record<string, unknown>;
|
|
946
|
+
return { ...fallback, store: false } as unknown;
|
|
727
947
|
}
|
|
728
948
|
}
|
|
729
|
-
|
|
949
|
+
// No apiKey available for upload — force store:false to avoid 413
|
|
950
|
+
return {
|
|
951
|
+
...(payloadToCheck as Record<string, unknown>),
|
|
952
|
+
store: false,
|
|
953
|
+
} as unknown;
|
|
730
954
|
});
|
|
731
955
|
}
|
|
732
956
|
|
|
@@ -769,16 +993,21 @@ export default function metaMedia(pi: ExtensionAPI): void {
|
|
|
769
993
|
);
|
|
770
994
|
continue;
|
|
771
995
|
}
|
|
772
|
-
//
|
|
773
|
-
//
|
|
774
|
-
//
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
996
|
+
// Files API is required for reliability — inline base64 hits the
|
|
997
|
+
// ~20 MB `store=true` persistence limit (24 MB → 413). We keep inline
|
|
998
|
+
// only for small files (<15 MB); larger files will be uploaded via
|
|
999
|
+
// Files API in before_provider_request or via /meta-video.
|
|
1000
|
+
if (stat.size > STORE_SAFE_INLINE_BYTES) {
|
|
1001
|
+
if (stat.size > INLINE_LIMIT_BYTES) {
|
|
1002
|
+
safeNotify(
|
|
1003
|
+
ctx,
|
|
1004
|
+
`Large file ${basename(absolute)} (${(stat.size / 1_000_000).toFixed(1)} MB) — use /meta-video or /meta-audio for Files API upload (50MB inline / 15MB store-safe limit)`,
|
|
1005
|
+
"info",
|
|
1006
|
+
);
|
|
1007
|
+
continue;
|
|
1008
|
+
}
|
|
1009
|
+
// 15-50 MB: still smuggle inline but before_provider_request
|
|
1010
|
+
// will promote to Files API (if apiKey available) and set store:false
|
|
782
1011
|
}
|
|
783
1012
|
try {
|
|
784
1013
|
const { dataUrl } = dataUrlForFile(absolute);
|
|
@@ -879,7 +1108,7 @@ export default function metaMedia(pi: ExtensionAPI): void {
|
|
|
879
1108
|
if (file_id) {
|
|
880
1109
|
videoBlock = { type: "input_file", file_id };
|
|
881
1110
|
} else if (url) {
|
|
882
|
-
videoBlock =
|
|
1111
|
+
videoBlock = mediaInputFromSource(url, "video.mp4", "video/mp4");
|
|
883
1112
|
} else if (path) {
|
|
884
1113
|
const absolute = resolve(
|
|
885
1114
|
(ctx as unknown as { cwd?: string }).cwd ?? process.cwd(),
|
|
@@ -890,20 +1119,30 @@ export default function metaMedia(pi: ExtensionAPI): void {
|
|
|
890
1119
|
const stat = statSync(absolute);
|
|
891
1120
|
if (stat.size > FILES_API_LIMIT_BYTES)
|
|
892
1121
|
throw new Error(`File too large (1 GiB limit): ${path}`);
|
|
893
|
-
|
|
1122
|
+
// Docs recommend Files API (upload → file_id) for video. Inline
|
|
1123
|
+
// base64 also hits the ~20 MB `store=true` persistence limit
|
|
1124
|
+
// (24 MB → HTTP 413) even when under the 50 MB inline cap.
|
|
1125
|
+
// Use Files API for any video above the store-safe threshold
|
|
1126
|
+
// and for all larger media; keep inline only for tiny files.
|
|
1127
|
+
const useFilesApi = stat.size > STORE_SAFE_INLINE_BYTES;
|
|
1128
|
+
if (useFilesApi) {
|
|
894
1129
|
const up = await uploadMetaFile(apiKey, absolute);
|
|
895
1130
|
videoBlock = { type: "input_file", file_id: up.id };
|
|
896
1131
|
} else {
|
|
897
1132
|
const { dataUrl } = dataUrlForFile(absolute);
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
|
|
1133
|
+
videoBlock = mediaInputFromSource(
|
|
1134
|
+
dataUrl,
|
|
1135
|
+
basename(absolute),
|
|
1136
|
+
"video/mp4",
|
|
1137
|
+
);
|
|
901
1138
|
}
|
|
902
1139
|
} else {
|
|
903
1140
|
throw new Error("No source");
|
|
904
1141
|
}
|
|
1142
|
+
const selectedModel = model ?? "muse-spark-1.2";
|
|
905
1143
|
const payload: Record<string, unknown> = {
|
|
906
|
-
model:
|
|
1144
|
+
model: selectedModel,
|
|
1145
|
+
store: false,
|
|
907
1146
|
input: [
|
|
908
1147
|
{
|
|
909
1148
|
type: "message",
|
|
@@ -914,12 +1153,14 @@ export default function metaMedia(pi: ExtensionAPI): void {
|
|
|
914
1153
|
],
|
|
915
1154
|
},
|
|
916
1155
|
],
|
|
917
|
-
max_output_tokens: Math.max(max_output_tokens ??
|
|
1156
|
+
max_output_tokens: Math.max(max_output_tokens ?? 8000, 4000),
|
|
918
1157
|
};
|
|
919
1158
|
const { text, raw } = await callMetaResponses(apiKey, payload, signal);
|
|
1159
|
+
const usage = extractMetaResponseUsage(raw, selectedModel);
|
|
920
1160
|
return {
|
|
921
1161
|
content: [{ type: "text", text }],
|
|
922
|
-
details: { raw, videoBlock },
|
|
1162
|
+
details: { raw, videoBlock, usage },
|
|
1163
|
+
usage,
|
|
923
1164
|
};
|
|
924
1165
|
} catch (e) {
|
|
925
1166
|
return {
|
|
@@ -980,9 +1221,7 @@ export default function metaMedia(pi: ExtensionAPI): void {
|
|
|
980
1221
|
if (file_id) {
|
|
981
1222
|
audioBlock = { type: "input_file", file_id };
|
|
982
1223
|
} else if (url) {
|
|
983
|
-
|
|
984
|
-
// Prefer input_file file_url for public audio URL.
|
|
985
|
-
audioBlock = { type: "input_file", file_url: url };
|
|
1224
|
+
audioBlock = mediaInputFromSource(url, "audio.bin");
|
|
986
1225
|
} else if (path) {
|
|
987
1226
|
const absolute = resolve(
|
|
988
1227
|
(ctx as unknown as { cwd?: string }).cwd ?? process.cwd(),
|
|
@@ -999,23 +1238,23 @@ export default function metaMedia(pi: ExtensionAPI): void {
|
|
|
999
1238
|
`Unsupported audio mime ${mime}, need audio/wav or audio/mpeg`,
|
|
1000
1239
|
);
|
|
1001
1240
|
}
|
|
1002
|
-
if (stat.size >
|
|
1241
|
+
if (stat.size > STORE_SAFE_INLINE_BYTES) {
|
|
1003
1242
|
const up = await uploadMetaFile(apiKey, absolute);
|
|
1004
1243
|
audioBlock = { type: "input_file", file_id: up.id };
|
|
1005
1244
|
} else {
|
|
1006
|
-
const
|
|
1007
|
-
|
|
1008
|
-
|
|
1009
|
-
|
|
1010
|
-
|
|
1011
|
-
|
|
1012
|
-
};
|
|
1245
|
+
const { dataUrl } = dataUrlForFile(absolute);
|
|
1246
|
+
audioBlock = mediaInputFromSource(
|
|
1247
|
+
dataUrl,
|
|
1248
|
+
basename(absolute),
|
|
1249
|
+
mime,
|
|
1250
|
+
);
|
|
1013
1251
|
}
|
|
1014
1252
|
} else {
|
|
1015
1253
|
throw new Error("No audio source");
|
|
1016
1254
|
}
|
|
1017
1255
|
const payload: Record<string, unknown> = {
|
|
1018
1256
|
model: model ?? "muse-spark-1.2",
|
|
1257
|
+
store: false,
|
|
1019
1258
|
input: [
|
|
1020
1259
|
{
|
|
1021
1260
|
type: "message",
|
|
@@ -1098,7 +1337,7 @@ export default function metaMedia(pi: ExtensionAPI): void {
|
|
|
1098
1337
|
content: [
|
|
1099
1338
|
{
|
|
1100
1339
|
type: "text",
|
|
1101
|
-
text: `Uploaded ${basename(absolute)} → ${result.id} (${result.bytes} bytes, status: ${result.status}${result.expires_at ? `, expires_at: ${result.expires_at}` : ""}). Use with {type:"input_file", file_id:"${result.id}"}
|
|
1340
|
+
text: `Uploaded ${basename(absolute)} → ${result.id} (${result.bytes} bytes, status: ${result.status}${result.expires_at ? `, expires_at: ${result.expires_at}` : ""}). Use with {type:"input_file", file_id:"${result.id}"}.`,
|
|
1102
1341
|
},
|
|
1103
1342
|
],
|
|
1104
1343
|
details: result,
|
|
@@ -1151,7 +1390,7 @@ export default function metaMedia(pi: ExtensionAPI): void {
|
|
|
1151
1390
|
);
|
|
1152
1391
|
let fileBlock: ResponsesContentBlock;
|
|
1153
1392
|
if (file_id) fileBlock = { type: "input_file", file_id };
|
|
1154
|
-
else if (url) fileBlock =
|
|
1393
|
+
else if (url) fileBlock = mediaInputFromSource(url);
|
|
1155
1394
|
else if (path) {
|
|
1156
1395
|
const absolute = resolve(
|
|
1157
1396
|
(ctx as unknown as { cwd?: string }).cwd ?? process.cwd(),
|
|
@@ -1162,18 +1401,17 @@ export default function metaMedia(pi: ExtensionAPI): void {
|
|
|
1162
1401
|
const stat = statSync(absolute);
|
|
1163
1402
|
if (stat.size > FILES_API_LIMIT_BYTES)
|
|
1164
1403
|
throw new Error(`File too large (1 GiB limit)`);
|
|
1165
|
-
if (stat.size >
|
|
1404
|
+
if (stat.size > STORE_SAFE_INLINE_BYTES) {
|
|
1166
1405
|
const up = await uploadMetaFile(apiKey, absolute);
|
|
1167
1406
|
fileBlock = { type: "input_file", file_id: up.id };
|
|
1168
1407
|
} else {
|
|
1169
1408
|
const { dataUrl, mime } = dataUrlForFile(absolute);
|
|
1170
|
-
|
|
1171
|
-
// Responses API inline: {type:"input_file", file_data, filename}
|
|
1172
|
-
fileBlock = { type: "input_file", file_data: dataUrl, filename };
|
|
1409
|
+
fileBlock = mediaInputFromSource(dataUrl, basename(absolute), mime);
|
|
1173
1410
|
}
|
|
1174
1411
|
} else throw new Error("No source");
|
|
1175
1412
|
const payload: Record<string, unknown> = {
|
|
1176
1413
|
model: model ?? "muse-spark-1.2",
|
|
1414
|
+
store: false,
|
|
1177
1415
|
input: [
|
|
1178
1416
|
{
|
|
1179
1417
|
type: "message",
|
|
@@ -1227,9 +1465,9 @@ export default function metaMedia(pi: ExtensionAPI): void {
|
|
|
1227
1465
|
const source = firstSpace === -1 ? trimmed : trimmed.slice(0, firstSpace);
|
|
1228
1466
|
const prompt =
|
|
1229
1467
|
firstSpace === -1
|
|
1230
|
-
? "
|
|
1468
|
+
? "Summarize what happens in this video in detail. Transcribe any speech verbatim. List key visual moments in 5 bullet points."
|
|
1231
1469
|
: trimmed.slice(firstSpace + 1).trim() ||
|
|
1232
|
-
"
|
|
1470
|
+
"Summarize what happens in this video in detail. Transcribe any speech verbatim.";
|
|
1233
1471
|
const isFileId = source.startsWith("file-");
|
|
1234
1472
|
const isUrl = source.startsWith("https://") || source.startsWith("data:");
|
|
1235
1473
|
const toolArgs: Record<string, unknown> = { prompt };
|
|
@@ -1245,21 +1483,24 @@ export default function metaMedia(pi: ExtensionAPI): void {
|
|
|
1245
1483
|
);
|
|
1246
1484
|
let block: ResponsesContentBlock;
|
|
1247
1485
|
if (isFileId) block = { type: "input_file", file_id: source };
|
|
1248
|
-
else if (isUrl)
|
|
1486
|
+
else if (isUrl)
|
|
1487
|
+
block = mediaInputFromSource(source, "video.mp4", "video/mp4");
|
|
1249
1488
|
else {
|
|
1250
1489
|
const abs = resolve(cwd, source);
|
|
1251
1490
|
if (!existsSync(abs)) throw new Error(`File not found: ${abs}`);
|
|
1252
1491
|
const stat = statSync(abs);
|
|
1253
|
-
if (stat.size >
|
|
1492
|
+
if (stat.size > STORE_SAFE_INLINE_BYTES) {
|
|
1254
1493
|
const up = await uploadMetaFile(apiKey, abs);
|
|
1255
1494
|
block = { type: "input_file", file_id: up.id };
|
|
1256
1495
|
} else {
|
|
1257
1496
|
const { dataUrl } = dataUrlForFile(abs);
|
|
1258
|
-
block =
|
|
1497
|
+
block = mediaInputFromSource(dataUrl, basename(abs), "video/mp4");
|
|
1259
1498
|
}
|
|
1260
1499
|
}
|
|
1261
|
-
const
|
|
1262
|
-
|
|
1500
|
+
const model = "muse-spark-1.2";
|
|
1501
|
+
const { text, raw } = await callMetaResponses(apiKey, {
|
|
1502
|
+
model,
|
|
1503
|
+
store: false,
|
|
1263
1504
|
input: [
|
|
1264
1505
|
{
|
|
1265
1506
|
type: "message",
|
|
@@ -1270,9 +1511,14 @@ export default function metaMedia(pi: ExtensionAPI): void {
|
|
|
1270
1511
|
],
|
|
1271
1512
|
},
|
|
1272
1513
|
],
|
|
1273
|
-
max_output_tokens:
|
|
1514
|
+
max_output_tokens: 8000,
|
|
1274
1515
|
});
|
|
1275
|
-
|
|
1516
|
+
const usage = extractMetaResponseUsage(raw, model);
|
|
1517
|
+
const usageText = usage ? `\n\n${formatMetaResponseUsage(usage)}` : "";
|
|
1518
|
+
const displayText = text.trim()
|
|
1519
|
+
? text.slice(0, 4000)
|
|
1520
|
+
: `No summary returned (raw preview: ${JSON.stringify(raw).slice(0, 1500)})`;
|
|
1521
|
+
safeNotify(ctx, `${displayText}${usageText}`, "info");
|
|
1276
1522
|
} catch (e) {
|
|
1277
1523
|
safeNotify(
|
|
1278
1524
|
ctx,
|
|
@@ -1312,25 +1558,22 @@ export default function metaMedia(pi: ExtensionAPI): void {
|
|
|
1312
1558
|
);
|
|
1313
1559
|
let block: ResponsesContentBlock;
|
|
1314
1560
|
if (isFileId) block = { type: "input_file", file_id: source };
|
|
1315
|
-
else if (isUrl) block =
|
|
1561
|
+
else if (isUrl) block = mediaInputFromSource(source, "audio.bin");
|
|
1316
1562
|
else {
|
|
1317
1563
|
const abs = resolve(cwd, source);
|
|
1318
1564
|
if (!existsSync(abs)) throw new Error(`File not found: ${abs}`);
|
|
1319
1565
|
const stat = statSync(abs);
|
|
1320
|
-
if (stat.size >
|
|
1566
|
+
if (stat.size > STORE_SAFE_INLINE_BYTES) {
|
|
1321
1567
|
const up = await uploadMetaFile(apiKey, abs);
|
|
1322
1568
|
block = { type: "input_file", file_id: up.id };
|
|
1323
1569
|
} else {
|
|
1324
|
-
const
|
|
1325
|
-
|
|
1326
|
-
block = {
|
|
1327
|
-
type: "input_audio",
|
|
1328
|
-
input_audio: { data, format: audioFormatForMime(mime) },
|
|
1329
|
-
};
|
|
1570
|
+
const { dataUrl, mime } = dataUrlForFile(abs);
|
|
1571
|
+
block = mediaInputFromSource(dataUrl, basename(abs), mime);
|
|
1330
1572
|
}
|
|
1331
1573
|
}
|
|
1332
1574
|
const { text } = await callMetaResponses(apiKey, {
|
|
1333
1575
|
model: "muse-spark-1.2",
|
|
1576
|
+
store: false,
|
|
1334
1577
|
input: [
|
|
1335
1578
|
{
|
|
1336
1579
|
type: "message",
|
|
@@ -1383,25 +1626,22 @@ export default function metaMedia(pi: ExtensionAPI): void {
|
|
|
1383
1626
|
);
|
|
1384
1627
|
let block: ResponsesContentBlock;
|
|
1385
1628
|
if (isFileId) block = { type: "input_file", file_id: source };
|
|
1386
|
-
else if (isUrl) block =
|
|
1629
|
+
else if (isUrl) block = mediaInputFromSource(source);
|
|
1387
1630
|
else {
|
|
1388
1631
|
const abs = resolve(cwd, source);
|
|
1389
1632
|
if (!existsSync(abs)) throw new Error(`File not found: ${abs}`);
|
|
1390
1633
|
const stat = statSync(abs);
|
|
1391
|
-
if (stat.size >
|
|
1634
|
+
if (stat.size > STORE_SAFE_INLINE_BYTES) {
|
|
1392
1635
|
const up = await uploadMetaFile(apiKey, abs);
|
|
1393
1636
|
block = { type: "input_file", file_id: up.id };
|
|
1394
1637
|
} else {
|
|
1395
|
-
const { dataUrl } = dataUrlForFile(abs);
|
|
1396
|
-
block =
|
|
1397
|
-
type: "input_file",
|
|
1398
|
-
file_data: dataUrl,
|
|
1399
|
-
filename: basename(abs),
|
|
1400
|
-
};
|
|
1638
|
+
const { dataUrl, mime } = dataUrlForFile(abs);
|
|
1639
|
+
block = mediaInputFromSource(dataUrl, basename(abs), mime);
|
|
1401
1640
|
}
|
|
1402
1641
|
}
|
|
1403
1642
|
const { text } = await callMetaResponses(apiKey, {
|
|
1404
1643
|
model: "muse-spark-1.2",
|
|
1644
|
+
store: false,
|
|
1405
1645
|
input: [
|
|
1406
1646
|
{
|
|
1407
1647
|
type: "message",
|