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.
@@ -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 { META_FILES_URL, META_API_BASE_URL, META_PROVIDER_ID } from "./meta.ts";
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 / audio : 50 MB limit
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) via input_audio {data, format}
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 audioFormatForMime(mime: string): "wav" | "mp3" {
85
- const lower = mime.toLowerCase();
86
- if (lower === "audio/wav" || lower === "audio/x-wav") return "wav";
87
- return "mp3"; // audio/mpeg, audio/mp3
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 (purpose && !(ALLOWED_PURPOSES as readonly string[]).includes(purpose)) {
335
+ if (!(ALLOWED_PURPOSES as readonly string[]).includes(purpose)) {
238
336
  throw new Error(`Invalid purpose: ${purpose}`);
239
337
  }
240
- let url: URL;
241
- try {
242
- url = new URL(META_FILES_URL);
243
- } catch (error) {
244
- throw new Error(`Invalid Meta Files URL: ${String(error)}`);
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
- // SDK shape: output_text
308
- if (typeof j.output_text === "string") return j.output_text;
309
- // output array
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") continue;
315
- if (item.type === "message" && Array.isArray(item.content)) {
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 (c.type === "output_text" && typeof c.text === "string")
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 (c.type === "refusal" && typeof c.refusal === "string")
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
- const res = await fetch(responsesUrl(), {
336
- method: "POST",
337
- headers: {
338
- Authorization: `Bearer ${apiKey}`,
339
- "Content-Type": "application/json",
340
- "x-api-version": "1.0.0",
341
- },
342
- body: JSON.stringify(payload),
343
- signal,
344
- });
345
- const text = await res.text();
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 correct
390
- // {type:"input_video", video_url:...} / {type:"input_audio", input_audio:{data,format}} /
391
- // {type:"input_file", file_data:...} blocks before the request is sent.
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
- // input_video with video_url supports both https and data: URLs per docs
434
- return { type: "input_video", video_url: url };
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
- const format = audioFormatForMime(effectiveMime);
445
- // Responses API input_audio shape: {type:"input_audio", input_audio:{data, format}}
446
- if (url.startsWith("data:")) {
447
- return { type: "input_audio", input_audio: { data, format } };
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
- if (url.startsWith("https://")) {
456
- return { type: "input_file", file_url: url };
457
- }
458
- // data:application/pdf;base64,... -> file_data
459
- return { type: "input_file", file_data: url, filename: "document.pdf" };
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
- function rewriteResponsesPayload(payload: unknown): {
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
- // For large inline data URLs (>50MB) we should upload via Files API instead of sending inline.
534
- // This is async and needs apiKey. We do it lazily in before_provider_request if we can get a key.
535
- // To avoid blocking forever we only upload when we can resolve a key from env or fallback.
536
- async function maybeUploadLargeInlineBlocks(
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
- // Need to handle both rewritten and original blocks — check all input_* that carry inline data
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 e = entry as Record<string, unknown>;
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
- const b64 = base64FromDataUrl(block.video_url as string);
567
- const bytes = Math.floor((b64.length * 3) / 4);
568
- if (bytes > INLINE_LIMIT_BYTES) {
569
- // upload: create temp file? Instead, upload directly via fetch with blob
570
- const mime = mimeForDataUrl(block.video_url as string) ?? "video/mp4";
571
- const blob = new Blob([Buffer.from(b64, "base64")], { type: mime });
572
- const form = new FormData();
573
- form.append(
574
- "file",
575
- blob,
576
- `video${mime === "video/mp4" ? ".mp4" : ""}`,
577
- );
578
- form.append("purpose", "user_data");
579
- const res = await fetch(META_FILES_URL, {
580
- method: "POST",
581
- headers: { Authorization: `Bearer ${apiKey}` },
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 b64 = base64FromDataUrl(block.file_data as string);
611
- const bytes = Math.floor((b64.length * 3) / 4);
612
- if (bytes > INLINE_LIMIT_BYTES) {
613
- const mime =
614
- mimeForDataUrl(block.file_data as string) ??
615
- "application/octet-stream";
616
- const blob = new Blob([Buffer.from(b64, "base64")], { type: mime });
617
- const form = new FormData();
618
- form.append(
619
- "file",
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
- const id = typeof j.id === "string" ? j.id : "";
686
- if (id) {
687
- uploaded++;
688
- newContent.push({ type: "input_file", file_id: id });
689
- continue;
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({ ...e, content: newContent });
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
- if (uploaded === 0) return { payload, uploaded: 0 };
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
- const original = event.payload;
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
- if (!rewritten) return undefined;
713
- // Try to also handle large inline → Files API upload if we can get a key.
714
- // We attempt to read key from env-adjacent modelRegistry is not available in this event's ctx,
715
- // so we best-effort fetch from process env or skip.
716
- // The tool path always uses explicit ctx.modelRegistry, so this is only for @file drag-drop.
717
- const apiKey =
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(rewrittenPayload, apiKey);
941
+ await maybeUploadLargeInlineBlocks(payloadToCheck, apiKey);
723
942
  return uploadedPayload as unknown;
724
943
  } catch {
725
- // fall back to rewritten without upload
726
- return rewrittenPayload as unknown;
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
- return rewrittenPayload as unknown;
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
- // For large files (>50MB) we could upload here, but we lack apiKey in input context sometimes.
773
- // Instead smuggle as data URL and let before_provider_request upload if needed.
774
- // To avoid OOM on huge files, skip inline if >50MB and notify to use /meta-video which does Files API.
775
- if (stat.size > INLINE_LIMIT_BYTES) {
776
- safeNotify(
777
- ctx,
778
- `Large file ${basename(absolute)} (${(stat.size / 1_000_000).toFixed(1)} MB) — use /meta-video or /meta-audio for Files API upload (50MB inline limit)`,
779
- "info",
780
- );
781
- continue;
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 = { type: "input_video", video_url: url };
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
- if (stat.size > INLINE_LIMIT_BYTES) {
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
- // Prefer input_file with file_data for small inline? Docs show input_file file_data and input_video video_url both work.
899
- // Use input_video with data URL for clarity.
900
- videoBlock = { type: "input_video", video_url: dataUrl };
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: model ?? "muse-spark-1.2",
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 ?? 4000, 4000),
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
- // For URLs, use input_audio with audio_url? Docs show Responses accepts input_audio with file_id or inline; for URL use input_file file_url as fallback.
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 > INLINE_LIMIT_BYTES) {
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 data = readFileSync(absolute).toString("base64");
1007
- const effectiveMime = typeof mime === "string" ? mime : "audio/wav";
1008
- const fmt = audioFormatForMime(effectiveMime);
1009
- audioBlock = {
1010
- type: "input_audio",
1011
- input_audio: { data, format: fmt },
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}"} or {type:"input_video", file_id} etc.`,
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 = { type: "input_file", file_url: url };
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 > INLINE_LIMIT_BYTES) {
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
- const filename = basename(absolute);
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
- ? "Describe what happens in this video. Transcribe any speech."
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
- "Describe what happens in this video.";
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) block = { type: "input_video", video_url: source };
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 > INLINE_LIMIT_BYTES) {
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 = { type: "input_video", video_url: dataUrl };
1502
+ block = mediaInputFromSource(dataUrl, basename(abs), "video/mp4");
1259
1503
  }
1260
1504
  }
1261
- const { text } = await callMetaResponses(apiKey, {
1262
- model: "muse-spark-1.2",
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: 4000,
1519
+ max_output_tokens: 8000,
1274
1520
  });
1275
- safeNotify(ctx, text.slice(0, 4000), "info");
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 = { type: "input_file", file_url: source };
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 > INLINE_LIMIT_BYTES) {
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 data = readFileSync(abs).toString("base64");
1325
- const mime = mimeForPath(abs) ?? "audio/wav";
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 = { type: "input_file", file_url: source };
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 > INLINE_LIMIT_BYTES) {
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",
@@ -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 input_video/input_audio at request time.
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.0",
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",