pi-meta-oauth 0.4.0 → 0.4.2
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/extensions/media.ts +513 -268
- package/extensions/meta.ts +1 -1
- 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
|
|
|
@@ -736,6 +960,11 @@ export default function metaMedia(pi: ExtensionAPI): void {
|
|
|
736
960
|
// so the before_provider_request hook can rewrite them.
|
|
737
961
|
pi.on("input", async (event, ctx) => {
|
|
738
962
|
if (!shouldEnableNativeRewrite()) return undefined;
|
|
963
|
+
// Only Meta's request hook knows how to rewrite these pseudo-images into
|
|
964
|
+
// typed video/audio/file blocks. Other providers (notably Codex) reject a
|
|
965
|
+
// video/mp4 ImageContent before the Meta tools can be called, so leave the
|
|
966
|
+
// path as text and let the active model invoke the direct Meta media tool.
|
|
967
|
+
if (ctx.model?.provider !== META_PROVIDER_ID) return undefined;
|
|
739
968
|
const text = event.text ?? "";
|
|
740
969
|
// Match @/path/to/file.mp4, @./file.wav, /abs/file.pdf, ./file.mp3 — conservative.
|
|
741
970
|
const filePattern =
|
|
@@ -769,16 +998,21 @@ export default function metaMedia(pi: ExtensionAPI): void {
|
|
|
769
998
|
);
|
|
770
999
|
continue;
|
|
771
1000
|
}
|
|
772
|
-
//
|
|
773
|
-
//
|
|
774
|
-
//
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
1001
|
+
// Files API is required for reliability — inline base64 hits the
|
|
1002
|
+
// ~20 MB `store=true` persistence limit (24 MB → 413). We keep inline
|
|
1003
|
+
// only for small files (<15 MB); larger files will be uploaded via
|
|
1004
|
+
// Files API in before_provider_request or via /meta-video.
|
|
1005
|
+
if (stat.size > STORE_SAFE_INLINE_BYTES) {
|
|
1006
|
+
if (stat.size > INLINE_LIMIT_BYTES) {
|
|
1007
|
+
safeNotify(
|
|
1008
|
+
ctx,
|
|
1009
|
+
`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)`,
|
|
1010
|
+
"info",
|
|
1011
|
+
);
|
|
1012
|
+
continue;
|
|
1013
|
+
}
|
|
1014
|
+
// 15-50 MB: still smuggle inline but before_provider_request
|
|
1015
|
+
// will promote to Files API (if apiKey available) and set store:false
|
|
782
1016
|
}
|
|
783
1017
|
try {
|
|
784
1018
|
const { dataUrl } = dataUrlForFile(absolute);
|
|
@@ -879,7 +1113,7 @@ export default function metaMedia(pi: ExtensionAPI): void {
|
|
|
879
1113
|
if (file_id) {
|
|
880
1114
|
videoBlock = { type: "input_file", file_id };
|
|
881
1115
|
} else if (url) {
|
|
882
|
-
videoBlock =
|
|
1116
|
+
videoBlock = mediaInputFromSource(url, "video.mp4", "video/mp4");
|
|
883
1117
|
} else if (path) {
|
|
884
1118
|
const absolute = resolve(
|
|
885
1119
|
(ctx as unknown as { cwd?: string }).cwd ?? process.cwd(),
|
|
@@ -890,20 +1124,30 @@ export default function metaMedia(pi: ExtensionAPI): void {
|
|
|
890
1124
|
const stat = statSync(absolute);
|
|
891
1125
|
if (stat.size > FILES_API_LIMIT_BYTES)
|
|
892
1126
|
throw new Error(`File too large (1 GiB limit): ${path}`);
|
|
893
|
-
|
|
1127
|
+
// Docs recommend Files API (upload → file_id) for video. Inline
|
|
1128
|
+
// base64 also hits the ~20 MB `store=true` persistence limit
|
|
1129
|
+
// (24 MB → HTTP 413) even when under the 50 MB inline cap.
|
|
1130
|
+
// Use Files API for any video above the store-safe threshold
|
|
1131
|
+
// and for all larger media; keep inline only for tiny files.
|
|
1132
|
+
const useFilesApi = stat.size > STORE_SAFE_INLINE_BYTES;
|
|
1133
|
+
if (useFilesApi) {
|
|
894
1134
|
const up = await uploadMetaFile(apiKey, absolute);
|
|
895
1135
|
videoBlock = { type: "input_file", file_id: up.id };
|
|
896
1136
|
} else {
|
|
897
1137
|
const { dataUrl } = dataUrlForFile(absolute);
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
|
|
1138
|
+
videoBlock = mediaInputFromSource(
|
|
1139
|
+
dataUrl,
|
|
1140
|
+
basename(absolute),
|
|
1141
|
+
"video/mp4",
|
|
1142
|
+
);
|
|
901
1143
|
}
|
|
902
1144
|
} else {
|
|
903
1145
|
throw new Error("No source");
|
|
904
1146
|
}
|
|
1147
|
+
const selectedModel = model ?? "muse-spark-1.2";
|
|
905
1148
|
const payload: Record<string, unknown> = {
|
|
906
|
-
model:
|
|
1149
|
+
model: selectedModel,
|
|
1150
|
+
store: false,
|
|
907
1151
|
input: [
|
|
908
1152
|
{
|
|
909
1153
|
type: "message",
|
|
@@ -914,12 +1158,14 @@ export default function metaMedia(pi: ExtensionAPI): void {
|
|
|
914
1158
|
],
|
|
915
1159
|
},
|
|
916
1160
|
],
|
|
917
|
-
max_output_tokens: Math.max(max_output_tokens ??
|
|
1161
|
+
max_output_tokens: Math.max(max_output_tokens ?? 8000, 4000),
|
|
918
1162
|
};
|
|
919
1163
|
const { text, raw } = await callMetaResponses(apiKey, payload, signal);
|
|
1164
|
+
const usage = extractMetaResponseUsage(raw, selectedModel);
|
|
920
1165
|
return {
|
|
921
1166
|
content: [{ type: "text", text }],
|
|
922
|
-
details: { raw, videoBlock },
|
|
1167
|
+
details: { raw, videoBlock, usage },
|
|
1168
|
+
usage,
|
|
923
1169
|
};
|
|
924
1170
|
} catch (e) {
|
|
925
1171
|
return {
|
|
@@ -980,9 +1226,7 @@ export default function metaMedia(pi: ExtensionAPI): void {
|
|
|
980
1226
|
if (file_id) {
|
|
981
1227
|
audioBlock = { type: "input_file", file_id };
|
|
982
1228
|
} else if (url) {
|
|
983
|
-
|
|
984
|
-
// Prefer input_file file_url for public audio URL.
|
|
985
|
-
audioBlock = { type: "input_file", file_url: url };
|
|
1229
|
+
audioBlock = mediaInputFromSource(url, "audio.bin");
|
|
986
1230
|
} else if (path) {
|
|
987
1231
|
const absolute = resolve(
|
|
988
1232
|
(ctx as unknown as { cwd?: string }).cwd ?? process.cwd(),
|
|
@@ -999,23 +1243,23 @@ export default function metaMedia(pi: ExtensionAPI): void {
|
|
|
999
1243
|
`Unsupported audio mime ${mime}, need audio/wav or audio/mpeg`,
|
|
1000
1244
|
);
|
|
1001
1245
|
}
|
|
1002
|
-
if (stat.size >
|
|
1246
|
+
if (stat.size > STORE_SAFE_INLINE_BYTES) {
|
|
1003
1247
|
const up = await uploadMetaFile(apiKey, absolute);
|
|
1004
1248
|
audioBlock = { type: "input_file", file_id: up.id };
|
|
1005
1249
|
} else {
|
|
1006
|
-
const
|
|
1007
|
-
|
|
1008
|
-
|
|
1009
|
-
|
|
1010
|
-
|
|
1011
|
-
|
|
1012
|
-
};
|
|
1250
|
+
const { dataUrl } = dataUrlForFile(absolute);
|
|
1251
|
+
audioBlock = mediaInputFromSource(
|
|
1252
|
+
dataUrl,
|
|
1253
|
+
basename(absolute),
|
|
1254
|
+
mime,
|
|
1255
|
+
);
|
|
1013
1256
|
}
|
|
1014
1257
|
} else {
|
|
1015
1258
|
throw new Error("No audio source");
|
|
1016
1259
|
}
|
|
1017
1260
|
const payload: Record<string, unknown> = {
|
|
1018
1261
|
model: model ?? "muse-spark-1.2",
|
|
1262
|
+
store: false,
|
|
1019
1263
|
input: [
|
|
1020
1264
|
{
|
|
1021
1265
|
type: "message",
|
|
@@ -1098,7 +1342,7 @@ export default function metaMedia(pi: ExtensionAPI): void {
|
|
|
1098
1342
|
content: [
|
|
1099
1343
|
{
|
|
1100
1344
|
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}"}
|
|
1345
|
+
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
1346
|
},
|
|
1103
1347
|
],
|
|
1104
1348
|
details: result,
|
|
@@ -1151,7 +1395,7 @@ export default function metaMedia(pi: ExtensionAPI): void {
|
|
|
1151
1395
|
);
|
|
1152
1396
|
let fileBlock: ResponsesContentBlock;
|
|
1153
1397
|
if (file_id) fileBlock = { type: "input_file", file_id };
|
|
1154
|
-
else if (url) fileBlock =
|
|
1398
|
+
else if (url) fileBlock = mediaInputFromSource(url);
|
|
1155
1399
|
else if (path) {
|
|
1156
1400
|
const absolute = resolve(
|
|
1157
1401
|
(ctx as unknown as { cwd?: string }).cwd ?? process.cwd(),
|
|
@@ -1162,18 +1406,17 @@ export default function metaMedia(pi: ExtensionAPI): void {
|
|
|
1162
1406
|
const stat = statSync(absolute);
|
|
1163
1407
|
if (stat.size > FILES_API_LIMIT_BYTES)
|
|
1164
1408
|
throw new Error(`File too large (1 GiB limit)`);
|
|
1165
|
-
if (stat.size >
|
|
1409
|
+
if (stat.size > STORE_SAFE_INLINE_BYTES) {
|
|
1166
1410
|
const up = await uploadMetaFile(apiKey, absolute);
|
|
1167
1411
|
fileBlock = { type: "input_file", file_id: up.id };
|
|
1168
1412
|
} else {
|
|
1169
1413
|
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 };
|
|
1414
|
+
fileBlock = mediaInputFromSource(dataUrl, basename(absolute), mime);
|
|
1173
1415
|
}
|
|
1174
1416
|
} else throw new Error("No source");
|
|
1175
1417
|
const payload: Record<string, unknown> = {
|
|
1176
1418
|
model: model ?? "muse-spark-1.2",
|
|
1419
|
+
store: false,
|
|
1177
1420
|
input: [
|
|
1178
1421
|
{
|
|
1179
1422
|
type: "message",
|
|
@@ -1227,9 +1470,9 @@ export default function metaMedia(pi: ExtensionAPI): void {
|
|
|
1227
1470
|
const source = firstSpace === -1 ? trimmed : trimmed.slice(0, firstSpace);
|
|
1228
1471
|
const prompt =
|
|
1229
1472
|
firstSpace === -1
|
|
1230
|
-
? "
|
|
1473
|
+
? "Summarize what happens in this video in detail. Transcribe any speech verbatim. List key visual moments in 5 bullet points."
|
|
1231
1474
|
: trimmed.slice(firstSpace + 1).trim() ||
|
|
1232
|
-
"
|
|
1475
|
+
"Summarize what happens in this video in detail. Transcribe any speech verbatim.";
|
|
1233
1476
|
const isFileId = source.startsWith("file-");
|
|
1234
1477
|
const isUrl = source.startsWith("https://") || source.startsWith("data:");
|
|
1235
1478
|
const toolArgs: Record<string, unknown> = { prompt };
|
|
@@ -1245,21 +1488,24 @@ export default function metaMedia(pi: ExtensionAPI): void {
|
|
|
1245
1488
|
);
|
|
1246
1489
|
let block: ResponsesContentBlock;
|
|
1247
1490
|
if (isFileId) block = { type: "input_file", file_id: source };
|
|
1248
|
-
else if (isUrl)
|
|
1491
|
+
else if (isUrl)
|
|
1492
|
+
block = mediaInputFromSource(source, "video.mp4", "video/mp4");
|
|
1249
1493
|
else {
|
|
1250
1494
|
const abs = resolve(cwd, source);
|
|
1251
1495
|
if (!existsSync(abs)) throw new Error(`File not found: ${abs}`);
|
|
1252
1496
|
const stat = statSync(abs);
|
|
1253
|
-
if (stat.size >
|
|
1497
|
+
if (stat.size > STORE_SAFE_INLINE_BYTES) {
|
|
1254
1498
|
const up = await uploadMetaFile(apiKey, abs);
|
|
1255
1499
|
block = { type: "input_file", file_id: up.id };
|
|
1256
1500
|
} else {
|
|
1257
1501
|
const { dataUrl } = dataUrlForFile(abs);
|
|
1258
|
-
block =
|
|
1502
|
+
block = mediaInputFromSource(dataUrl, basename(abs), "video/mp4");
|
|
1259
1503
|
}
|
|
1260
1504
|
}
|
|
1261
|
-
const
|
|
1262
|
-
|
|
1505
|
+
const model = "muse-spark-1.2";
|
|
1506
|
+
const { text, raw } = await callMetaResponses(apiKey, {
|
|
1507
|
+
model,
|
|
1508
|
+
store: false,
|
|
1263
1509
|
input: [
|
|
1264
1510
|
{
|
|
1265
1511
|
type: "message",
|
|
@@ -1270,9 +1516,14 @@ export default function metaMedia(pi: ExtensionAPI): void {
|
|
|
1270
1516
|
],
|
|
1271
1517
|
},
|
|
1272
1518
|
],
|
|
1273
|
-
max_output_tokens:
|
|
1519
|
+
max_output_tokens: 8000,
|
|
1274
1520
|
});
|
|
1275
|
-
|
|
1521
|
+
const usage = extractMetaResponseUsage(raw, model);
|
|
1522
|
+
const usageText = usage ? `\n\n${formatMetaResponseUsage(usage)}` : "";
|
|
1523
|
+
const displayText = text.trim()
|
|
1524
|
+
? text.slice(0, 4000)
|
|
1525
|
+
: `No summary returned (raw preview: ${JSON.stringify(raw).slice(0, 1500)})`;
|
|
1526
|
+
safeNotify(ctx, `${displayText}${usageText}`, "info");
|
|
1276
1527
|
} catch (e) {
|
|
1277
1528
|
safeNotify(
|
|
1278
1529
|
ctx,
|
|
@@ -1312,25 +1563,22 @@ export default function metaMedia(pi: ExtensionAPI): void {
|
|
|
1312
1563
|
);
|
|
1313
1564
|
let block: ResponsesContentBlock;
|
|
1314
1565
|
if (isFileId) block = { type: "input_file", file_id: source };
|
|
1315
|
-
else if (isUrl) block =
|
|
1566
|
+
else if (isUrl) block = mediaInputFromSource(source, "audio.bin");
|
|
1316
1567
|
else {
|
|
1317
1568
|
const abs = resolve(cwd, source);
|
|
1318
1569
|
if (!existsSync(abs)) throw new Error(`File not found: ${abs}`);
|
|
1319
1570
|
const stat = statSync(abs);
|
|
1320
|
-
if (stat.size >
|
|
1571
|
+
if (stat.size > STORE_SAFE_INLINE_BYTES) {
|
|
1321
1572
|
const up = await uploadMetaFile(apiKey, abs);
|
|
1322
1573
|
block = { type: "input_file", file_id: up.id };
|
|
1323
1574
|
} else {
|
|
1324
|
-
const
|
|
1325
|
-
|
|
1326
|
-
block = {
|
|
1327
|
-
type: "input_audio",
|
|
1328
|
-
input_audio: { data, format: audioFormatForMime(mime) },
|
|
1329
|
-
};
|
|
1575
|
+
const { dataUrl, mime } = dataUrlForFile(abs);
|
|
1576
|
+
block = mediaInputFromSource(dataUrl, basename(abs), mime);
|
|
1330
1577
|
}
|
|
1331
1578
|
}
|
|
1332
1579
|
const { text } = await callMetaResponses(apiKey, {
|
|
1333
1580
|
model: "muse-spark-1.2",
|
|
1581
|
+
store: false,
|
|
1334
1582
|
input: [
|
|
1335
1583
|
{
|
|
1336
1584
|
type: "message",
|
|
@@ -1383,25 +1631,22 @@ export default function metaMedia(pi: ExtensionAPI): void {
|
|
|
1383
1631
|
);
|
|
1384
1632
|
let block: ResponsesContentBlock;
|
|
1385
1633
|
if (isFileId) block = { type: "input_file", file_id: source };
|
|
1386
|
-
else if (isUrl) block =
|
|
1634
|
+
else if (isUrl) block = mediaInputFromSource(source);
|
|
1387
1635
|
else {
|
|
1388
1636
|
const abs = resolve(cwd, source);
|
|
1389
1637
|
if (!existsSync(abs)) throw new Error(`File not found: ${abs}`);
|
|
1390
1638
|
const stat = statSync(abs);
|
|
1391
|
-
if (stat.size >
|
|
1639
|
+
if (stat.size > STORE_SAFE_INLINE_BYTES) {
|
|
1392
1640
|
const up = await uploadMetaFile(apiKey, abs);
|
|
1393
1641
|
block = { type: "input_file", file_id: up.id };
|
|
1394
1642
|
} else {
|
|
1395
|
-
const { dataUrl } = dataUrlForFile(abs);
|
|
1396
|
-
block =
|
|
1397
|
-
type: "input_file",
|
|
1398
|
-
file_data: dataUrl,
|
|
1399
|
-
filename: basename(abs),
|
|
1400
|
-
};
|
|
1643
|
+
const { dataUrl, mime } = dataUrlForFile(abs);
|
|
1644
|
+
block = mediaInputFromSource(dataUrl, basename(abs), mime);
|
|
1401
1645
|
}
|
|
1402
1646
|
}
|
|
1403
1647
|
const { text } = await callMetaResponses(apiKey, {
|
|
1404
1648
|
model: "muse-spark-1.2",
|
|
1649
|
+
store: false,
|
|
1405
1650
|
input: [
|
|
1406
1651
|
{
|
|
1407
1652
|
type: "message",
|
package/extensions/meta.ts
CHANGED
|
@@ -361,7 +361,7 @@ function modalitiesToInput(
|
|
|
361
361
|
const input: string[] = ["text"];
|
|
362
362
|
if (modalities.includes("image")) input.push("image");
|
|
363
363
|
// Advertised via cast until pi-ai expands Model.input union beyond text|image.
|
|
364
|
-
// The before_provider_request hook in media.ts rewrites these to
|
|
364
|
+
// The before_provider_request hook in media.ts rewrites these to typed media blocks at request time.
|
|
365
365
|
if (modalities.includes("video")) input.push("video");
|
|
366
366
|
if (modalities.includes("audio")) input.push("audio");
|
|
367
367
|
// Some catalogs expose "document"/"pdf" as a modality for PDF handling.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-meta-oauth",
|
|
3
|
-
"version": "0.4.
|
|
3
|
+
"version": "0.4.2",
|
|
4
4
|
"description": "Meta Model API OAuth provider and Muse-style voice dictation for pi",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"pi-package",
|
|
@@ -37,7 +37,8 @@
|
|
|
37
37
|
"peerDependencies": {
|
|
38
38
|
"@earendil-works/pi-ai": ">=0.83.0 <0.85.0",
|
|
39
39
|
"@earendil-works/pi-coding-agent": ">=0.83.0 <0.85.0",
|
|
40
|
-
"@earendil-works/pi-tui": ">=0.83.0 <0.85.0"
|
|
40
|
+
"@earendil-works/pi-tui": ">=0.83.0 <0.85.0",
|
|
41
|
+
"typebox": "*"
|
|
41
42
|
},
|
|
42
43
|
"devDependencies": {
|
|
43
44
|
"@earendil-works/pi-ai": "0.83.0",
|