pi-meta-oauth 0.4.4 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -8,7 +8,8 @@
8
8
 
9
9
  Meta Model API OAuth and Muse-style voice input for [pi](https://pi.dev).
10
10
 
11
- - Use Muse Spark models through Pi's `openai-responses` provider
11
+ - Use Muse Spark models through Pi's `openai-responses` provider (not `/chat/completions` — Muse prompt cache is ~0% there)
12
+ - Send `prompt_cache_retention: "24h"` on Meta Responses requests unless the payload already set a retention
12
13
  - Device authorization against `https://auth.meta.com`
13
14
  - Model API-key minting through `POST https://api.meta.ai/muse-code/key`
14
15
  - Dynamic Muse model catalog from `GET https://api.meta.ai/v1/models`
@@ -87,11 +88,13 @@ Fallback models use a 1,048,576-token context window, up to 256K output tokens,
87
88
 
88
89
  | id | pricing (input/output/cached) $/M |
89
90
  | --- | --- |
91
+ | `muse-spark-1.3` | 1.25 / 4.25 / 0.15 |
92
+ | `muse-spark-1.3-contributor` | 0.10 / 0.20 / 0.002 |
90
93
  | `muse-spark-1.2` | 1.25 / 4.25 / 0.15 |
91
94
  | `muse-spark-1.2-contributor` | 0.10 / 0.20 / 0.002 |
92
95
  | `muse-spark-1.1` | 1.25 / 4.25 / 0.15 |
93
96
 
94
- > **Contributor-model privacy:** the discounted contributor model allows Meta to use prompts and completions for product improvement, including training future Meta models. Use the standard `muse-spark-1.2` model if you do not want the contributor terms. See [Meta's model documentation](https://dev.meta.ai/docs/models).
97
+ > **Contributor-model privacy:** discounted contributor models allow Meta to use prompts and completions for product improvement, including training future Meta models. Use a standard model such as `muse-spark-1.3` if you do not want the contributor terms. See [Meta's model documentation](https://dev.meta.ai/docs/models).
95
98
 
96
99
  To scope Pi's model picker to Meta models:
97
100
 
@@ -122,11 +125,19 @@ catalog.
122
125
 
123
126
  ```bash
124
127
  pi --list-models meta
125
- pi -p --provider meta --model muse-spark-1.2 "Reply exactly: META_OK"
128
+ pi -p --provider meta --model muse-spark-1.3 "Reply exactly: META_OK"
126
129
  bun run typecheck
127
130
  bun test
128
131
  ```
129
132
 
133
+ `bun test` is hermetic unless a Meta credential is already available. The live cache-hit probe makes real billable API calls when a credential resolves: two identical `/v1/responses` calls (asserting `cached_tokens` on the second), plus one 2s retry if that second call misses cache. The credential is resolved, in order, from `PI_META_LIVE_API_KEY`, `META_API_KEY`, `MODEL_API_KEY`, or the minted key from `~/.pi/agent/auth.json` after `/login meta` (skipped if expired). OAuth is enough — you do not need a separate key. Skipped when no valid credential exists (CI):
134
+
135
+ ```bash
136
+ bun test tests/meta-cache.test.ts
137
+ # or, if you are not logged in:
138
+ PI_META_LIVE_API_KEY='LLM|...' bun test tests/meta-cache.test.ts
139
+ ```
140
+
130
141
  ## Publish to npm and pi.dev
131
142
 
132
143
  The `pi-package` keyword makes the package discoverable at <https://pi.dev/packages>. Publishing is handled by `.github/workflows/publish.yml` when a `v*` tag is pushed. Each release publishes the canonical `pi-meta-oauth` package to npm and a scoped `@blockedpath/pi-meta-oauth` mirror to GitHub Packages.
@@ -1,5 +1,9 @@
1
1
  import type { Usage } from "@earendil-works/pi-ai";
2
- import { META_API_BASE_URL, metaFallbackCost } from "../meta.ts";
2
+ import {
3
+ applyMetaResponsesCacheHints,
4
+ META_API_BASE_URL,
5
+ metaFallbackCost,
6
+ } from "../meta.ts";
3
7
 
4
8
  function record(value: unknown): Record<string, unknown> | undefined {
5
9
  return value !== null && typeof value === "object"
@@ -20,7 +24,11 @@ function tokenCount(...values: unknown[]): number | undefined {
20
24
  return undefined;
21
25
  }
22
26
 
23
- function responsesErrorDetail(json: unknown, text: string, statusText: string): string {
27
+ function responsesErrorDetail(
28
+ json: unknown,
29
+ text: string,
30
+ statusText: string,
31
+ ): string {
24
32
  const body = record(json);
25
33
  if (body?.error !== undefined) {
26
34
  return JSON.stringify(body.error).slice(0, 1000);
@@ -77,10 +85,8 @@ export function extractMetaResponseUsage(
77
85
  const outputTokens = output ?? 0;
78
86
  const reasoning = Math.min(
79
87
  outputTokens,
80
- tokenCount(
81
- outputDetails?.reasoning_tokens,
82
- outputDetails?.reasoningTokens,
83
- ) ?? 0,
88
+ tokenCount(outputDetails?.reasoning_tokens, outputDetails?.reasoningTokens) ??
89
+ 0,
84
90
  );
85
91
  const totalTokens =
86
92
  tokenCount(usage.total_tokens, usage.totalTokens) ??
@@ -127,8 +133,7 @@ export function extractResponsesText(json: unknown): string {
127
133
  if (Array.isArray(output)) {
128
134
  const texts: string[] = [];
129
135
  for (const item of output as Record<string, unknown>[]) {
130
- if (!item || typeof item !== "object" || item.type !== "message")
131
- continue;
136
+ if (!item || typeof item !== "object" || item.type !== "message") continue;
132
137
  if (typeof item.content === "string" && item.content.trim())
133
138
  texts.push(item.content);
134
139
  if (Array.isArray(item.content)) {
@@ -160,6 +165,7 @@ export async function callMetaResponses(
160
165
  signal?: AbortSignal,
161
166
  ): Promise<{ text: string; raw: unknown }> {
162
167
  payload.store = false;
168
+ applyMetaResponsesCacheHints(payload);
163
169
  const res = await fetch(`${META_API_BASE_URL}/responses`, {
164
170
  method: "POST",
165
171
  headers: {
@@ -48,7 +48,10 @@ import {
48
48
  } from "./media/responses.ts";
49
49
 
50
50
  export { mediaInputFromSource } from "./media/mime.ts";
51
- export { maybeUploadLargeInlineBlocks, rewriteResponsesPayload } from "./media/payload.ts";
51
+ export {
52
+ maybeUploadLargeInlineBlocks,
53
+ rewriteResponsesPayload,
54
+ } from "./media/payload.ts";
52
55
  export {
53
56
  extractMetaResponseUsage,
54
57
  extractResponsesText,
@@ -205,13 +208,12 @@ export default function metaMedia(pi: ExtensionAPI): void {
205
208
  }
206
209
  if (apiKey) {
207
210
  try {
208
- const { payload: uploadedPayload } =
209
- await maybeUploadLargeInlineBlocks(
210
- payloadToCheck,
211
- apiKey,
212
- STORE_SAFE_INLINE_BYTES,
213
- ctx.signal,
214
- );
211
+ const { payload: uploadedPayload } = await maybeUploadLargeInlineBlocks(
212
+ payloadToCheck,
213
+ apiKey,
214
+ STORE_SAFE_INLINE_BYTES,
215
+ ctx.signal,
216
+ );
215
217
  return uploadedPayload as unknown;
216
218
  } catch {
217
219
  const fallback = payloadToCheck as Record<string, unknown>;
@@ -296,7 +298,8 @@ export default function metaMedia(pi: ExtensionAPI): void {
296
298
  label: "Meta Video Describe",
297
299
  description:
298
300
  "Analyze an MP4 video with Muse Spark, including visuals and embedded speech. Accepts a local path, public HTTPS/data URL, or Meta file_id. Results are capped by max_chars; when truncated, the full text is saved to a temp file for continuation with read offset/limit.",
299
- promptSnippet: "Inspect video and embedded audio when the active model cannot",
301
+ promptSnippet:
302
+ "Inspect video and embedded audio when the active model cannot",
300
303
  promptGuidelines: [
301
304
  "Use meta_describe_video when the task depends on an MP4 video the active model cannot inspect directly.",
302
305
  "Give meta_describe_video a task-specific prompt that asks for the exact visual, transcript, timestamp, or defect evidence needed next.",
@@ -304,31 +307,55 @@ export default function metaMedia(pi: ExtensionAPI): void {
304
307
  ],
305
308
  parameters: Type.Object(
306
309
  {
307
- path: Type.Optional(Type.String({ description: "Local .mp4/.m4v path, relative to cwd or absolute" })),
308
- url: Type.Optional(Type.String({ description: "Public HTTPS or data:video/mp4 URL" })),
309
- file_id: Type.Optional(Type.String({ description: "Existing Meta Files API id" })),
310
- prompt: Type.String({ description: "What evidence to extract from the video" }),
311
- model: Type.Optional(Type.String({ description: "Muse model id, default muse-spark-1.2" })),
312
- max_output_tokens: Type.Optional(Type.Integer({
313
- minimum: MIN_MEDIA_MAX_OUTPUT_TOKENS,
314
- maximum: MAX_MEDIA_MAX_OUTPUT_TOKENS,
315
- description: `Muse generation budget, default ${DEFAULT_MEDIA_MAX_OUTPUT_TOKENS}`,
316
- })),
317
- max_chars: Type.Optional(Type.Integer({
318
- minimum: 1_000,
319
- maximum: MAX_MEDIA_MAX_CHARS,
320
- description: `Maximum result characters returned inline, default ${DEFAULT_MEDIA_MAX_CHARS}`,
321
- })),
310
+ path: Type.Optional(
311
+ Type.String({
312
+ description: "Local .mp4/.m4v path, relative to cwd or absolute",
313
+ }),
314
+ ),
315
+ url: Type.Optional(
316
+ Type.String({ description: "Public HTTPS or data:video/mp4 URL" }),
317
+ ),
318
+ file_id: Type.Optional(
319
+ Type.String({ description: "Existing Meta Files API id" }),
320
+ ),
321
+ prompt: Type.String({
322
+ description: "What evidence to extract from the video",
323
+ }),
324
+ model: Type.Optional(
325
+ Type.String({ description: "Muse model id, default muse-spark-1.2" }),
326
+ ),
327
+ max_output_tokens: Type.Optional(
328
+ Type.Integer({
329
+ minimum: MIN_MEDIA_MAX_OUTPUT_TOKENS,
330
+ maximum: MAX_MEDIA_MAX_OUTPUT_TOKENS,
331
+ description: `Muse generation budget, default ${DEFAULT_MEDIA_MAX_OUTPUT_TOKENS}`,
332
+ }),
333
+ ),
334
+ max_chars: Type.Optional(
335
+ Type.Integer({
336
+ minimum: 1_000,
337
+ maximum: MAX_MEDIA_MAX_CHARS,
338
+ description: `Maximum result characters returned inline, default ${DEFAULT_MEDIA_MAX_CHARS}`,
339
+ }),
340
+ ),
322
341
  },
323
342
  { additionalProperties: false },
324
343
  ),
325
344
  async execute(toolCallId, params, signal, _onUpdate, ctx) {
326
- const { path, url, file_id, prompt, model, max_output_tokens, max_chars } = params as {
327
- path?: string; url?: string; file_id?: string; prompt: string; model?: string;
328
- max_output_tokens?: number; max_chars?: number;
329
- };
345
+ const { path, url, file_id, prompt, model, max_output_tokens, max_chars } =
346
+ params as {
347
+ path?: string;
348
+ url?: string;
349
+ file_id?: string;
350
+ prompt: string;
351
+ model?: string;
352
+ max_output_tokens?: number;
353
+ max_chars?: number;
354
+ };
330
355
  if ([path, url, file_id].filter(Boolean).length !== 1) {
331
- throw new Error("meta_describe_video requires exactly one of path, url, or file_id");
356
+ throw new Error(
357
+ "meta_describe_video requires exactly one of path, url, or file_id",
358
+ );
332
359
  }
333
360
  try {
334
361
  const apiKey = await safeGetMetaApiKey(ctx as ExtensionContext);
@@ -341,26 +368,51 @@ export default function metaMedia(pi: ExtensionAPI): void {
341
368
  const absolute = resolve(ctx.cwd, path as string);
342
369
  if (!existsSync(absolute)) throw new Error(`File not found: ${absolute}`);
343
370
  const stat = statSync(absolute);
344
- if (stat.size > FILES_API_LIMIT_BYTES) throw new Error(`File too large (1 GiB limit): ${path}`);
371
+ if (stat.size > FILES_API_LIMIT_BYTES)
372
+ throw new Error(`File too large (1 GiB limit): ${path}`);
345
373
  rejectWrongMime("video", path as string, mimeForPath(absolute));
346
374
  if (stat.size > STORE_SAFE_INLINE_BYTES) {
347
- const upload = await uploadMetaFile(apiKey, absolute, automaticExpiry(), signal);
375
+ const upload = await uploadMetaFile(
376
+ apiKey,
377
+ absolute,
378
+ automaticExpiry(),
379
+ signal,
380
+ );
348
381
  videoBlock = { type: "input_file", file_id: upload.id };
349
382
  } else {
350
- videoBlock = mediaInputFromSource(dataUrlForFile(absolute).dataUrl, basename(absolute), "video/mp4");
383
+ videoBlock = mediaInputFromSource(
384
+ dataUrlForFile(absolute).dataUrl,
385
+ basename(absolute),
386
+ "video/mp4",
387
+ );
351
388
  }
352
389
  }
353
390
  const selectedModel = model ?? "muse-spark-1.2";
354
- const { text, raw } = await callMetaResponses(apiKey, {
355
- model: selectedModel,
356
- store: false,
357
- input: [{ type: "message", role: "user", content: [
358
- { type: "input_text", text: prompt },
359
- videoBlock as Record<string, unknown>,
360
- ] }],
361
- max_output_tokens: mediaMaxOutputTokens(max_output_tokens),
362
- }, signal);
363
- const output = await prepareMediaOutput({ text, response: raw, identity: toolCallId, maxChars: max_chars });
391
+ const { text, raw } = await callMetaResponses(
392
+ apiKey,
393
+ {
394
+ model: selectedModel,
395
+ store: false,
396
+ input: [
397
+ {
398
+ type: "message",
399
+ role: "user",
400
+ content: [
401
+ { type: "input_text", text: prompt },
402
+ videoBlock as Record<string, unknown>,
403
+ ],
404
+ },
405
+ ],
406
+ max_output_tokens: mediaMaxOutputTokens(max_output_tokens),
407
+ },
408
+ signal,
409
+ );
410
+ const output = await prepareMediaOutput({
411
+ text,
412
+ response: raw,
413
+ identity: toolCallId,
414
+ maxChars: max_chars,
415
+ });
364
416
  const usage = extractMetaResponseUsage(raw, selectedModel);
365
417
  return {
366
418
  content: [{ type: "text", text: output.text }],
@@ -386,30 +438,52 @@ export default function metaMedia(pi: ExtensionAPI): void {
386
438
  parameters: Type.Object(
387
439
  {
388
440
  path: Type.Optional(Type.String({ description: "Local .wav/.mp3 path" })),
389
- url: Type.Optional(Type.String({ description: "Public HTTPS or data audio URL" })),
390
- file_id: Type.Optional(Type.String({ description: "Existing Meta Files API id" })),
391
- prompt: Type.Optional(Type.String({ description: "Transcription or audio-analysis instruction" })),
392
- model: Type.Optional(Type.String({ description: "Muse model id, default muse-spark-1.2" })),
393
- max_output_tokens: Type.Optional(Type.Integer({
394
- minimum: MIN_MEDIA_MAX_OUTPUT_TOKENS,
395
- maximum: MAX_MEDIA_MAX_OUTPUT_TOKENS,
396
- description: `Muse generation budget, default ${DEFAULT_MEDIA_MAX_OUTPUT_TOKENS}`,
397
- })),
398
- max_chars: Type.Optional(Type.Integer({
399
- minimum: 1_000,
400
- maximum: MAX_MEDIA_MAX_CHARS,
401
- description: `Maximum transcript characters returned inline, default ${DEFAULT_MEDIA_MAX_CHARS}`,
402
- })),
441
+ url: Type.Optional(
442
+ Type.String({ description: "Public HTTPS or data audio URL" }),
443
+ ),
444
+ file_id: Type.Optional(
445
+ Type.String({ description: "Existing Meta Files API id" }),
446
+ ),
447
+ prompt: Type.Optional(
448
+ Type.String({
449
+ description: "Transcription or audio-analysis instruction",
450
+ }),
451
+ ),
452
+ model: Type.Optional(
453
+ Type.String({ description: "Muse model id, default muse-spark-1.2" }),
454
+ ),
455
+ max_output_tokens: Type.Optional(
456
+ Type.Integer({
457
+ minimum: MIN_MEDIA_MAX_OUTPUT_TOKENS,
458
+ maximum: MAX_MEDIA_MAX_OUTPUT_TOKENS,
459
+ description: `Muse generation budget, default ${DEFAULT_MEDIA_MAX_OUTPUT_TOKENS}`,
460
+ }),
461
+ ),
462
+ max_chars: Type.Optional(
463
+ Type.Integer({
464
+ minimum: 1_000,
465
+ maximum: MAX_MEDIA_MAX_CHARS,
466
+ description: `Maximum transcript characters returned inline, default ${DEFAULT_MEDIA_MAX_CHARS}`,
467
+ }),
468
+ ),
403
469
  },
404
470
  { additionalProperties: false },
405
471
  ),
406
472
  async execute(toolCallId, params, signal, _onUpdate, ctx) {
407
- const { path, url, file_id, prompt, model, max_output_tokens, max_chars } = params as {
408
- path?: string; url?: string; file_id?: string; prompt?: string; model?: string;
409
- max_output_tokens?: number; max_chars?: number;
410
- };
473
+ const { path, url, file_id, prompt, model, max_output_tokens, max_chars } =
474
+ params as {
475
+ path?: string;
476
+ url?: string;
477
+ file_id?: string;
478
+ prompt?: string;
479
+ model?: string;
480
+ max_output_tokens?: number;
481
+ max_chars?: number;
482
+ };
411
483
  if ([path, url, file_id].filter(Boolean).length !== 1) {
412
- throw new Error("meta_transcribe_audio requires exactly one of path, url, or file_id");
484
+ throw new Error(
485
+ "meta_transcribe_audio requires exactly one of path, url, or file_id",
486
+ );
413
487
  }
414
488
  try {
415
489
  const apiKey = await safeGetMetaApiKey(ctx as ExtensionContext);
@@ -422,27 +496,55 @@ export default function metaMedia(pi: ExtensionAPI): void {
422
496
  const absolute = resolve(ctx.cwd, path as string);
423
497
  if (!existsSync(absolute)) throw new Error(`File not found: ${absolute}`);
424
498
  const stat = statSync(absolute);
425
- if (stat.size > FILES_API_LIMIT_BYTES) throw new Error("Audio too large (1 GiB limit)");
499
+ if (stat.size > FILES_API_LIMIT_BYTES)
500
+ throw new Error("Audio too large (1 GiB limit)");
426
501
  const mime = mimeForPath(absolute);
427
502
  rejectWrongMime("audio", path as string, mime);
428
503
  if (stat.size > STORE_SAFE_INLINE_BYTES) {
429
- const upload = await uploadMetaFile(apiKey, absolute, automaticExpiry(), signal);
504
+ const upload = await uploadMetaFile(
505
+ apiKey,
506
+ absolute,
507
+ automaticExpiry(),
508
+ signal,
509
+ );
430
510
  audioBlock = { type: "input_file", file_id: upload.id };
431
511
  } else {
432
- audioBlock = mediaInputFromSource(dataUrlForFile(absolute).dataUrl, basename(absolute), mime);
512
+ audioBlock = mediaInputFromSource(
513
+ dataUrlForFile(absolute).dataUrl,
514
+ basename(absolute),
515
+ mime,
516
+ );
433
517
  }
434
518
  }
435
519
  const selectedModel = model ?? "muse-spark-1.2";
436
- const { text, raw } = await callMetaResponses(apiKey, {
437
- model: selectedModel,
438
- store: false,
439
- input: [{ type: "message", role: "user", content: [
440
- { type: "input_text", text: prompt ?? "Transcribe this audio. Return only the transcript." },
441
- audioBlock as Record<string, unknown>,
442
- ] }],
443
- max_output_tokens: mediaMaxOutputTokens(max_output_tokens),
444
- }, signal);
445
- const output = await prepareMediaOutput({ text, response: raw, identity: toolCallId, maxChars: max_chars });
520
+ const { text, raw } = await callMetaResponses(
521
+ apiKey,
522
+ {
523
+ model: selectedModel,
524
+ store: false,
525
+ input: [
526
+ {
527
+ type: "message",
528
+ role: "user",
529
+ content: [
530
+ {
531
+ type: "input_text",
532
+ text: prompt ?? "Transcribe this audio. Return only the transcript.",
533
+ },
534
+ audioBlock as Record<string, unknown>,
535
+ ],
536
+ },
537
+ ],
538
+ max_output_tokens: mediaMaxOutputTokens(max_output_tokens),
539
+ },
540
+ signal,
541
+ );
542
+ const output = await prepareMediaOutput({
543
+ text,
544
+ response: raw,
545
+ identity: toolCallId,
546
+ maxChars: max_chars,
547
+ });
446
548
  const usage = extractMetaResponseUsage(raw, selectedModel);
447
549
  return {
448
550
  content: [{ type: "text", text: output.text }],
@@ -463,30 +565,42 @@ export default function metaMedia(pi: ExtensionAPI): void {
463
565
  parameters: Type.Object(
464
566
  {
465
567
  path: Type.String({ description: "Local file path to upload" }),
466
- expires_after_seconds: Type.Optional(Type.Integer({
467
- minimum: MIN_UPLOAD_EXPIRY_SECONDS,
468
- maximum: MAX_UPLOAD_EXPIRY_SECONDS,
469
- description: `Expiry in seconds, default ${EXPLICIT_UPLOAD_EXPIRY_SECONDS} (7 days)`,
470
- })),
471
- retain: Type.Optional(Type.Boolean({ description: "Keep indefinitely instead of applying an expiry" })),
568
+ expires_after_seconds: Type.Optional(
569
+ Type.Integer({
570
+ minimum: MIN_UPLOAD_EXPIRY_SECONDS,
571
+ maximum: MAX_UPLOAD_EXPIRY_SECONDS,
572
+ description: `Expiry in seconds, default ${EXPLICIT_UPLOAD_EXPIRY_SECONDS} (7 days)`,
573
+ }),
574
+ ),
575
+ retain: Type.Optional(
576
+ Type.Boolean({
577
+ description: "Keep indefinitely instead of applying an expiry",
578
+ }),
579
+ ),
472
580
  },
473
581
  { additionalProperties: false },
474
582
  ),
475
583
  async execute(_toolCallId, params, signal, _onUpdate, ctx) {
476
584
  const { path, expires_after_seconds, retain } = params as {
477
- path: string; expires_after_seconds?: number; retain?: boolean;
585
+ path: string;
586
+ expires_after_seconds?: number;
587
+ retain?: boolean;
478
588
  };
479
589
  if (retain && expires_after_seconds !== undefined) {
480
- throw new Error("meta_upload_file accepts either retain=true or expires_after_seconds, not both");
590
+ throw new Error(
591
+ "meta_upload_file accepts either retain=true or expires_after_seconds, not both",
592
+ );
481
593
  }
482
594
  try {
483
595
  const apiKey = await safeGetMetaApiKey(ctx as ExtensionContext);
484
596
  const absolute = resolve(ctx.cwd, path);
485
597
  if (!existsSync(absolute)) throw new Error(`File not found: ${absolute}`);
486
- const expiresAfter = retain ? undefined : {
487
- anchor: "created_at" as const,
488
- seconds: expires_after_seconds ?? EXPLICIT_UPLOAD_EXPIRY_SECONDS,
489
- };
598
+ const expiresAfter = retain
599
+ ? undefined
600
+ : {
601
+ anchor: "created_at" as const,
602
+ seconds: expires_after_seconds ?? EXPLICIT_UPLOAD_EXPIRY_SECONDS,
603
+ };
490
604
  const result = await uploadMetaFile(apiKey, absolute, expiresAfter, signal);
491
605
  const retention = retain
492
606
  ? "retained without expiry"
@@ -494,7 +608,12 @@ export default function metaMedia(pi: ExtensionAPI): void {
494
608
  ? `expires_at ${result.expires_at}`
495
609
  : `expires after ${expiresAfter?.seconds} seconds`;
496
610
  return {
497
- content: [{ type: "text", text: `Uploaded ${basename(absolute)} to ${result.id} (${result.bytes} bytes, ${retention}).` }],
611
+ content: [
612
+ {
613
+ type: "text",
614
+ text: `Uploaded ${basename(absolute)} to ${result.id} (${result.bytes} bytes, ${retention}).`,
615
+ },
616
+ ],
498
617
  details: result,
499
618
  };
500
619
  } catch (error) {
@@ -508,7 +627,8 @@ export default function metaMedia(pi: ExtensionAPI): void {
508
627
  label: "Meta File Analyze",
509
628
  description:
510
629
  "Analyze one or more images, PDFs, videos, or audio files with Muse Spark. Use path/url/file_id for one source, or ordered sources for comparisons. Images and PDF pages share Meta's 50-image budget; PDFs include text from the first 100 pages and images from the first 50 pages. Truncated text is saved for continuation with read offset/limit.",
511
- promptSnippet: "Inspect images, PDFs, or mixed media when the active model cannot",
630
+ promptSnippet:
631
+ "Inspect images, PDFs, or mixed media when the active model cannot",
512
632
  promptGuidelines: [
513
633
  "Use meta_analyze_file when the task depends on an image, PDF, or generic media file the active model cannot inspect directly.",
514
634
  "Give meta_analyze_file a task-specific prompt that asks for the exact visible text, layout, comparison, defect, or action evidence needed next.",
@@ -517,61 +637,134 @@ export default function metaMedia(pi: ExtensionAPI): void {
517
637
  parameters: Type.Object(
518
638
  {
519
639
  path: Type.Optional(Type.String({ description: "Single local file path" })),
520
- url: Type.Optional(Type.String({ description: "Single public HTTPS or data URL" })),
521
- file_id: Type.Optional(Type.String({ description: "Single existing Meta file_id" })),
522
- sources: Type.Optional(Type.Array(Type.Object({
523
- source: Type.String({ description: "Local path, HTTPS/data URL, or Meta file_id" }),
524
- label: Type.Optional(Type.String({ description: "Human-readable label such as Before or After" })),
525
- }, { additionalProperties: false }), {
526
- minItems: 1,
527
- maxItems: MAX_ANALYSIS_SOURCES,
528
- description: "Ordered sources for comparison or combined analysis",
529
- })),
640
+ url: Type.Optional(
641
+ Type.String({ description: "Single public HTTPS or data URL" }),
642
+ ),
643
+ file_id: Type.Optional(
644
+ Type.String({ description: "Single existing Meta file_id" }),
645
+ ),
646
+ sources: Type.Optional(
647
+ Type.Array(
648
+ Type.Object(
649
+ {
650
+ source: Type.String({
651
+ description: "Local path, HTTPS/data URL, or Meta file_id",
652
+ }),
653
+ label: Type.Optional(
654
+ Type.String({
655
+ description: "Human-readable label such as Before or After",
656
+ }),
657
+ ),
658
+ },
659
+ { additionalProperties: false },
660
+ ),
661
+ {
662
+ minItems: 1,
663
+ maxItems: MAX_ANALYSIS_SOURCES,
664
+ description: "Ordered sources for comparison or combined analysis",
665
+ },
666
+ ),
667
+ ),
530
668
  prompt: Type.String({ description: "What evidence to extract or compare" }),
531
- model: Type.Optional(Type.String({ description: "Muse model id, default muse-spark-1.2" })),
532
- max_output_tokens: Type.Optional(Type.Integer({
533
- minimum: MIN_MEDIA_MAX_OUTPUT_TOKENS,
534
- maximum: MAX_MEDIA_MAX_OUTPUT_TOKENS,
535
- description: `Muse generation budget, default ${DEFAULT_MEDIA_MAX_OUTPUT_TOKENS}`,
536
- })),
537
- max_chars: Type.Optional(Type.Integer({
538
- minimum: 1_000,
539
- maximum: MAX_MEDIA_MAX_CHARS,
540
- description: `Maximum result characters returned inline, default ${DEFAULT_MEDIA_MAX_CHARS}`,
541
- })),
669
+ model: Type.Optional(
670
+ Type.String({ description: "Muse model id, default muse-spark-1.2" }),
671
+ ),
672
+ max_output_tokens: Type.Optional(
673
+ Type.Integer({
674
+ minimum: MIN_MEDIA_MAX_OUTPUT_TOKENS,
675
+ maximum: MAX_MEDIA_MAX_OUTPUT_TOKENS,
676
+ description: `Muse generation budget, default ${DEFAULT_MEDIA_MAX_OUTPUT_TOKENS}`,
677
+ }),
678
+ ),
679
+ max_chars: Type.Optional(
680
+ Type.Integer({
681
+ minimum: 1_000,
682
+ maximum: MAX_MEDIA_MAX_CHARS,
683
+ description: `Maximum result characters returned inline, default ${DEFAULT_MEDIA_MAX_CHARS}`,
684
+ }),
685
+ ),
542
686
  },
543
687
  { additionalProperties: false },
544
688
  ),
545
689
  async execute(toolCallId, params, signal, _onUpdate, ctx) {
546
- const { path, url, file_id, sources, prompt, model, max_output_tokens, max_chars } = params as {
547
- path?: string; url?: string; file_id?: string; sources?: AnalysisSource[];
548
- prompt: string; model?: string; max_output_tokens?: number; max_chars?: number;
690
+ const {
691
+ path,
692
+ url,
693
+ file_id,
694
+ sources,
695
+ prompt,
696
+ model,
697
+ max_output_tokens,
698
+ max_chars,
699
+ } = params as {
700
+ path?: string;
701
+ url?: string;
702
+ file_id?: string;
703
+ sources?: AnalysisSource[];
704
+ prompt: string;
705
+ model?: string;
706
+ max_output_tokens?: number;
707
+ max_chars?: number;
549
708
  };
550
- const legacySources = [path, url, file_id].filter((value): value is string => Boolean(value));
551
- if (legacySources.length > 1 || (legacySources.length === 1 && sources) || (legacySources.length === 0 && !sources)) {
552
- throw new Error("meta_analyze_file requires exactly one path/url/file_id or a sources array");
709
+ const legacySources = [path, url, file_id].filter((value): value is string =>
710
+ Boolean(value),
711
+ );
712
+ if (
713
+ legacySources.length > 1 ||
714
+ (legacySources.length === 1 && sources) ||
715
+ (legacySources.length === 0 && !sources)
716
+ ) {
717
+ throw new Error(
718
+ "meta_analyze_file requires exactly one path/url/file_id or a sources array",
719
+ );
553
720
  }
554
721
  if (sources && sources.length > MAX_ANALYSIS_SOURCES) {
555
- throw new Error(`meta_analyze_file supports at most ${MAX_ANALYSIS_SOURCES} sources`);
722
+ throw new Error(
723
+ `meta_analyze_file supports at most ${MAX_ANALYSIS_SOURCES} sources`,
724
+ );
556
725
  }
557
726
  try {
558
727
  const apiKey = await safeGetMetaApiKey(ctx as ExtensionContext);
559
- const requestedSources: AnalysisSource[] = sources ?? [{ source: legacySources[0] as string }];
560
- const content: ResponsesContentBlock[] = [{ type: "input_text", text: prompt }];
728
+ const requestedSources: AnalysisSource[] = sources ?? [
729
+ { source: legacySources[0] as string },
730
+ ];
731
+ const content: ResponsesContentBlock[] = [
732
+ { type: "input_text", text: prompt },
733
+ ];
561
734
  for (const [index, item] of requestedSources.entries()) {
562
735
  if (requestedSources.length > 1 || item.label) {
563
- content.push({ type: "input_text", text: `${sourceLabel(item, index)}:` });
736
+ content.push({
737
+ type: "input_text",
738
+ text: `${sourceLabel(item, index)}:`,
739
+ });
564
740
  }
565
- content.push(await resolveGenericMediaSource(apiKey, ctx.cwd, item.source, signal));
741
+ content.push(
742
+ await resolveGenericMediaSource(apiKey, ctx.cwd, item.source, signal),
743
+ );
566
744
  }
567
745
  const selectedModel = model ?? "muse-spark-1.2";
568
- const { text, raw } = await callMetaResponses(apiKey, {
569
- model: selectedModel,
570
- store: false,
571
- input: [{ type: "message", role: "user", content: content as Record<string, unknown>[] }],
572
- max_output_tokens: mediaMaxOutputTokens(max_output_tokens),
573
- }, signal);
574
- const output = await prepareMediaOutput({ text, response: raw, identity: toolCallId, maxChars: max_chars });
746
+ const { text, raw } = await callMetaResponses(
747
+ apiKey,
748
+ {
749
+ model: selectedModel,
750
+ store: false,
751
+ input: [
752
+ {
753
+ type: "message",
754
+ role: "user",
755
+ content: content as Record<string, unknown>[],
756
+ },
757
+ ],
758
+ max_output_tokens: mediaMaxOutputTokens(max_output_tokens),
759
+ },
760
+ signal,
761
+ );
762
+ const output = await prepareMediaOutput({
763
+ text,
764
+ response: raw,
765
+ identity: toolCallId,
766
+ maxChars: max_chars,
767
+ });
575
768
  const usage = extractMetaResponseUsage(raw, selectedModel);
576
769
  return {
577
770
  content: [{ type: "text", text: output.text }],
@@ -737,8 +930,7 @@ export default function metaMedia(pi: ExtensionAPI): void {
737
930
  const apiKey = await safeGetMetaApiKey(ctx as unknown as ExtensionContext);
738
931
  let block: ResponsesContentBlock;
739
932
  if (isFileId(source)) block = { type: "input_file", file_id: source };
740
- else if (isRemoteMediaSource(source))
741
- block = mediaInputFromSource(source);
933
+ else if (isRemoteMediaSource(source)) block = mediaInputFromSource(source);
742
934
  else {
743
935
  const abs = resolve(cwd, source);
744
936
  if (!existsSync(abs)) throw new Error(`File not found: ${abs}`);
@@ -82,6 +82,56 @@ interface MetaCatalogModel {
82
82
  // See media.ts for the before_provider_request rewrite that makes @video/@audio work today
83
83
  // despite UserMessage.content being string | (TextContent | ImageContent)[] .
84
84
  const FALLBACK_MODELS: MetaProviderModel[] = [
85
+ {
86
+ id: "muse-spark-1.3",
87
+ name: "Muse Spark 1.3",
88
+ reasoning: true,
89
+ thinkingLevelMap: {
90
+ off: null,
91
+ minimal: "minimal",
92
+ low: "low",
93
+ medium: "medium",
94
+ high: "high",
95
+ xhigh: "xhigh",
96
+ max: null,
97
+ },
98
+ // SAFETY: Meta supports video/audio inputs, while pi-ai 0.83/0.84 types only expose text/image.
99
+ input: [
100
+ "text",
101
+ "image",
102
+ "video",
103
+ "audio",
104
+ ] as unknown as MetaProviderModel["input"],
105
+ cost: { input: 1.25, output: 4.25, cacheRead: 0.15, cacheWrite: 0 },
106
+ contextWindow: 1_048_576,
107
+ maxTokens: 256_000,
108
+ compat: { supportsReasoningEffort: true, supportsToolSearch: true },
109
+ },
110
+ {
111
+ id: "muse-spark-1.3-contributor",
112
+ name: "Muse Spark 1.3 Contributor",
113
+ reasoning: true,
114
+ thinkingLevelMap: {
115
+ off: null,
116
+ minimal: "minimal",
117
+ low: "low",
118
+ medium: "medium",
119
+ high: "high",
120
+ xhigh: "xhigh",
121
+ max: null,
122
+ },
123
+ // SAFETY: Meta supports video/audio inputs, while pi-ai 0.83/0.84 types only expose text/image.
124
+ input: [
125
+ "text",
126
+ "image",
127
+ "video",
128
+ "audio",
129
+ ] as unknown as MetaProviderModel["input"],
130
+ cost: { input: 0.1, output: 0.2, cacheRead: 0.002, cacheWrite: 0 },
131
+ contextWindow: 1_048_576,
132
+ maxTokens: 256_000,
133
+ compat: { supportsReasoningEffort: true, supportsToolSearch: true },
134
+ },
85
135
  {
86
136
  id: "muse-spark-1.2",
87
137
  name: "Muse Spark 1.2",
@@ -336,8 +386,7 @@ export async function refreshMetaToken(
336
386
  );
337
387
  // Pi 0.83 calls refreshToken(credentials). Pi 0.84 passes AbortSignal as
338
388
  // the second argument. Tests inject a fetch mock in that slot.
339
- const fetchImpl =
340
- typeof fetchOrSignal === "function" ? fetchOrSignal : fetch;
389
+ const fetchImpl = typeof fetchOrSignal === "function" ? fetchOrSignal : fetch;
341
390
  const signal = isAbortSignal(fetchOrSignal) ? fetchOrSignal : undefined;
342
391
  if (signal?.aborted) {
343
392
  throw new Error("Meta token refresh was cancelled");
@@ -582,6 +631,38 @@ export function metaFallbackCost(
582
631
  return FALLBACK_MODELS.find((model) => model.id === modelId)?.cost;
583
632
  }
584
633
 
634
+ /** Meta prompt-cache opt-in. Measured 0% hits on /chat/completions vs 93–99% on /responses with 24h. */
635
+ export const META_PROMPT_CACHE_RETENTION = "24h";
636
+
637
+ function asRecord(value: unknown): Record<string, unknown> | undefined {
638
+ return value !== null && typeof value === "object" && !Array.isArray(value)
639
+ ? (value as Record<string, unknown>)
640
+ : undefined;
641
+ }
642
+
643
+ /**
644
+ * Hermes-equivalent Responses hints for api.meta.ai:
645
+ * setdefault `prompt_cache_retention: 24h`, and drop `reasoning.effort: none`
646
+ * because Meta 400s on it.
647
+ */
648
+ export function applyMetaResponsesCacheHints(payload: unknown): unknown {
649
+ const body = asRecord(payload);
650
+ if (!body) return payload;
651
+ if (body.prompt_cache_retention === undefined) {
652
+ body.prompt_cache_retention = META_PROMPT_CACHE_RETENTION;
653
+ }
654
+ const reasoning = asRecord(body.reasoning);
655
+ if (
656
+ reasoning &&
657
+ (reasoning.effort === "none" ||
658
+ reasoning.effort === undefined ||
659
+ reasoning.effort === null)
660
+ ) {
661
+ delete body.reasoning;
662
+ }
663
+ return body;
664
+ }
665
+
585
666
  export function createMetaProviderConfig(): ProviderConfig {
586
667
  return {
587
668
  name: "Meta Model API",
@@ -614,4 +695,8 @@ export default function metaOAuthProvider(pi: ExtensionAPI): void {
614
695
  process.env["MODEL_API_KEY"] = process.env[META_ENV_VAR];
615
696
  }
616
697
  pi.registerProvider(META_PROVIDER_ID, createMetaProviderConfig());
698
+ pi.on("before_provider_request", (event, ctx) => {
699
+ if (ctx.model?.provider !== META_PROVIDER_ID) return undefined;
700
+ return applyMetaResponsesCacheHints(event.payload);
701
+ });
617
702
  }
@@ -53,6 +53,28 @@ export function formatAuthorization(apiKey: string): string {
53
53
  return apiKey.startsWith("OAuth ") ? apiKey : `OAuth ${apiKey}`;
54
54
  }
55
55
 
56
+ export function asrModel(): string {
57
+ return (
58
+ process.env.PI_META_VOICE_ASR_MODEL ??
59
+ process.env.MUSE_VOICE_ASR_MODEL ??
60
+ DEFAULT_ASR_MODEL
61
+ );
62
+ }
63
+
64
+ export function asrHandshake(apiKey: string): {
65
+ mode: "DEFAULT";
66
+ authorization: { accessToken: string };
67
+ audioEncoding: "PCM_16KHZ";
68
+ model: string;
69
+ } {
70
+ return {
71
+ mode: "DEFAULT",
72
+ authorization: { accessToken: formatAuthorization(apiKey) },
73
+ audioEncoding: "PCM_16KHZ",
74
+ model: asrModel(),
75
+ };
76
+ }
77
+
56
78
  export function asrEndpoint(sessionId: string): string {
57
79
  const configured =
58
80
  process.env.PI_META_VOICE_ASR_ENDPOINT ??
@@ -90,7 +112,9 @@ export async function socketMessageText(
90
112
  return undefined;
91
113
  }
92
114
 
93
- export function parseJsonObject(text: string): Record<string, unknown> | undefined {
115
+ export function parseJsonObject(
116
+ text: string,
117
+ ): Record<string, unknown> | undefined {
94
118
  try {
95
119
  const value = JSON.parse(text) as unknown;
96
120
  if (value && typeof value === "object" && !Array.isArray(value)) {
@@ -11,9 +11,8 @@ import { homedir } from "node:os";
11
11
  import { join } from "node:path";
12
12
  import {
13
13
  asrEndpoint,
14
+ asrHandshake,
14
15
  chooseTranscript,
15
- DEFAULT_ASR_MODEL,
16
- formatAuthorization,
17
16
  isNormalSocketClose,
18
17
  parseJsonObject,
19
18
  pcmAudioLevel,
@@ -25,7 +24,12 @@ import {
25
24
  supportedPlatformLabel,
26
25
  } from "./voice/helpers.ts";
27
26
 
28
- export { asrEndpoint, formatAuthorization, pcmAudioLevel } from "./voice/asr.ts";
27
+ export {
28
+ asrEndpoint,
29
+ asrHandshake,
30
+ formatAuthorization,
31
+ pcmAudioLevel,
32
+ } from "./voice/asr.ts";
29
33
 
30
34
  const AGENT_DIR = join(homedir(), ".pi/agent");
31
35
  const SETTINGS_FILE = join(AGENT_DIR, "pi-meta-oauth-voice.json");
@@ -253,10 +257,7 @@ class MetaVoiceController {
253
257
  this.audioLevel *= 0.78;
254
258
  }, 120);
255
259
  this.recordingLimitTimer = setTimeout(() => {
256
- ctx.ui.notify(
257
- "Voice recording reached the two-minute limit",
258
- "warning",
259
- );
260
+ ctx.ui.notify("Voice recording reached the two-minute limit", "warning");
260
261
  this.stop(ctx);
261
262
  }, MAX_RECORDING_MS);
262
263
  } catch (error) {
@@ -294,17 +295,7 @@ class MetaVoiceController {
294
295
  this.socket = socket;
295
296
  socket.addEventListener("open", () => {
296
297
  if (currentGeneration !== this.generation) return;
297
- socket.send(
298
- JSON.stringify({
299
- mode: "DEFAULT",
300
- authorization: { accessToken: formatAuthorization(apiKey) },
301
- audioEncoding: "PCM_16KHZ",
302
- model:
303
- process.env.PI_META_VOICE_ASR_MODEL ??
304
- process.env.MUSE_VOICE_ASR_MODEL ??
305
- DEFAULT_ASR_MODEL,
306
- }),
307
- );
298
+ socket.send(JSON.stringify(asrHandshake(apiKey)));
308
299
  });
309
300
  socket.addEventListener("message", (event) => {
310
301
  if (currentGeneration !== this.generation) return;
@@ -315,8 +306,7 @@ class MetaVoiceController {
315
306
  this.fail(ctx, new Error("could not connect to Muse's Meta ASR service"));
316
307
  });
317
308
  socket.addEventListener("close", (event) => {
318
- if (currentGeneration !== this.generation || this.phase === "idle")
319
- return;
309
+ if (currentGeneration !== this.generation || this.phase === "idle") return;
320
310
  if (
321
311
  this.phase === "stopping" &&
322
312
  this.transcript &&
@@ -328,8 +318,7 @@ class MetaVoiceController {
328
318
  this.fail(
329
319
  ctx,
330
320
  new Error(
331
- event.reason ||
332
- `Meta ASR connection closed (${event.code || "unknown"})`,
321
+ event.reason || `Meta ASR connection closed (${event.code || "unknown"})`,
333
322
  ),
334
323
  );
335
324
  });
@@ -593,9 +582,7 @@ class MetaVoiceController {
593
582
  this.generation += 1;
594
583
  this.clearTimers();
595
584
  this.closeResources();
596
- ctx.ui.setEditorText(
597
- combineEditorText(this.originalEditorText, transcript),
598
- );
585
+ ctx.ui.setEditorText(combineEditorText(this.originalEditorText, transcript));
599
586
  ctx.ui.setWidget("muse-voice", undefined);
600
587
  this.renderIdleStatus(ctx);
601
588
  if (transcript) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-meta-oauth",
3
- "version": "0.4.4",
3
+ "version": "0.5.0",
4
4
  "description": "Meta Model API OAuth provider and Muse-style voice dictation for pi",
5
5
  "keywords": [
6
6
  "pi-package",