pi-meta-oauth 0.4.4 → 0.6.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
@@ -4,24 +4,24 @@
4
4
 
5
5
  <!-- markdownlint-disable-next-line MD013 -->
6
6
  ![X (formerly Twitter) Follow](https://img.shields.io/twitter/follow/blockedpaths?style=flat&link=https%3A%2F%2Fx.com%2FBlockedPaths)
7
- [![npm version](https://img.shields.io/npm/v/pi-meta-oauth)](https://www.npmjs.com/package/pi-meta-oauth) [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) [![GitHub stars](https://img.shields.io/github/stars/BlockedPath/pi-meta-oauth?style=social)](https://github.com/BlockedPath/pi-meta-oauth/stargazers) [![Last Commit](https://img.shields.io/github/last-commit/BlockedPath/pi-meta-oauth)](https://github.com/BlockedPath/pi-meta-oauth/commits/main) [![Issues](https://img.shields.io/github/issues/BlockedPath/pi-meta-oauth)](https://github.com/BlockedPath/pi-meta-oauth/issues) [![PRs Welcome](https://img.shields.io/badge/PRs-welcome-brightgreen.svg)](https://github.com/BlockedPath/pi-meta-oauth/pulls) [![Conventional Commits](https://img.shields.io/badge/Conventional%20Commits-1.0.0-yellow.svg)](https://www.conventionalcommits.org/en/v1.0.0/) [![TypeScript](https://img.shields.io/badge/TypeScript-Ready-blue)](https://www.typescriptlang.org/) [![CI](https://github.com/BlockedPath/pi-meta-oauth/actions/workflows/ci.yml/badge.svg)](https://github.com/BlockedPath/pi-meta-oauth/actions/workflows/ci.yml) [![Pi compatible](https://img.shields.io/badge/pi-Compatible-blueviolet)](https://pi.dev)
7
+ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) [![GitHub stars](https://img.shields.io/github/stars/BlockedPath/pi-meta-oauth?style=social)](https://github.com/BlockedPath/pi-meta-oauth/stargazers) [![Last Commit](https://img.shields.io/github/last-commit/BlockedPath/pi-meta-oauth)](https://github.com/BlockedPath/pi-meta-oauth/commits/main) [![Issues](https://img.shields.io/github/issues/BlockedPath/pi-meta-oauth)](https://github.com/BlockedPath/pi-meta-oauth/issues) [![PRs Welcome](https://img.shields.io/badge/PRs-welcome-brightgreen.svg)](https://github.com/BlockedPath/pi-meta-oauth/pulls) [![Conventional Commits](https://img.shields.io/badge/Conventional%20Commits-1.0.0-yellow.svg)](https://www.conventionalcommits.org/en/v1.0.0/) [![TypeScript](https://img.shields.io/badge/TypeScript-Ready-blue)](https://www.typescriptlang.org/) [![CI](https://github.com/BlockedPath/pi-meta-oauth/actions/workflows/ci.yml/badge.svg)](https://github.com/BlockedPath/pi-meta-oauth/actions/workflows/ci.yml) [![Pi compatible](https://img.shields.io/badge/pi-Compatible-blueviolet)](https://pi.dev)
8
8
 
9
- Meta Model API OAuth and Muse-style voice input for [pi](https://pi.dev).
9
+ Meta Model API OAuth 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`
15
- - Toggle-based Meta voice dictation on macOS and Windows with a live green input meter
16
16
 
17
17
  ## Install
18
18
 
19
19
  ```bash
20
- # Local checkout
21
- pi install /absolute/path/to/pi-meta-oauth
20
+ # OAuth-only branch
21
+ pi install git:github.com/BlockedPath/pi-meta-oauth@meta-oauth-only
22
22
 
23
- # npm
24
- pi install npm:pi-meta-oauth
23
+ # Or from a local checkout
24
+ pi install /absolute/path/to/pi-meta-oauth
25
25
 
26
26
  pi --list-models meta
27
27
  ```
@@ -40,58 +40,19 @@ Pi displays a device code, opens the Meta authorization flow, and mints a Model
40
40
 
41
41
  The access key is re-minted daily.
42
42
 
43
- ## Voice input
44
-
45
- Voice mode runs on **macOS and Windows** (Linux is not yet supported). It uses the same Meta credential managed by this provider; a separate Muse login is not required.
46
-
47
- 1. Press **Alt+V** once to start recording.
48
- 2. Speak while the green microphone meter is visible in Pi's status bar.
49
- 3. Press **Alt+V** again to stop.
50
- 4. The transcript is inserted into Pi's editor for review before submission.
51
-
52
- Commands:
53
-
54
- - `/voice` — show voice status and privacy behavior
55
- - `/voice-on` — enable the Alt+V shortcut
56
- - `/voice-off` — disable voice input
57
-
58
- Audio is captured only between the two Alt+V presses and streamed as 16 kHz mono PCM to Muse Code's internal Meta ASR endpoint. No local speech-recognition model is downloaded. The endpoint is undocumented and may change in a future Muse release.
59
-
60
- **macOS:** uses `AVFoundation` via a Swift helper compiled with `xcrun swiftc` (first recording triggers the system microphone permission prompt).
61
-
62
- **Windows:** uses `winmm.dll` `waveIn*` via a C# helper. On first run pi tries to compile `extensions/voice/windows-audio.cs` with `csc.exe` (in-box .NET Framework) to `~/.pi/agent/bin/pi-meta-oauth-voice-v1.exe` for best performance. If no compiler is found it falls back to `extensions/voice/windows-audio.ps1` executed with `powershell.exe -ExecutionPolicy Bypass` (PowerShell 5.1 inbox, or `pwsh` 7 if present) which compiles the same capture code at runtime via `Add-Type`. No extra install is required. If recording fails, check **Settings → Privacy & security → Microphone** that access is allowed for desktop apps.
63
-
64
- Optional overrides (the `PI_META_*` name takes precedence):
65
-
66
- - `PI_META_VOICE_ASR_ENDPOINT` (legacy alias: `MUSE_VOICE_ASR_ENDPOINT`)
67
- - `PI_META_VOICE_ASR_MODEL` (legacy alias: `MUSE_VOICE_ASR_MODEL`)
68
-
69
- ## Media tools
70
-
71
- Text-only Pi models can delegate media inspection to Muse Spark without switching models:
72
-
73
- - `meta_analyze_file` inspects one or more images, PDFs, audio files, or videos. Use ordered `sources` with optional labels for comparisons such as before and after screenshots.
74
- - `meta_describe_video` analyzes MP4 visuals and embedded audio.
75
- - `meta_transcribe_audio` transcribes MP3 or WAV speech.
76
- - `meta_upload_file` uploads a large or reusable file and returns a Meta `file_id`.
77
-
78
- Give each analysis tool a task-specific prompt that asks for the evidence the calling model needs next. Analysis tools default to an 8,000-token Muse generation budget and accept `max_output_tokens` from 4,000 to 32,000. Inline tool output defaults to 20,000 characters and accepts `max_chars` up to 50,000. If a result is truncated, the complete text is saved to a temporary file so the agent can continue with Pi's `read` tool using `offset` and `limit`.
79
-
80
- Automatic uploads made during analysis expire after 24 hours. Explicit `meta_upload_file` uploads expire after seven days by default; set `expires_after_seconds` to choose another supported duration or `retain: true` to keep a file without expiry.
81
-
82
- Media tool failures are reported as failed Pi tool calls, allowing the calling model to retry or report the blocker. Media analysis is model-generated observation, so verify consequential details when another source is available.
83
-
84
43
  ## Models
85
44
 
86
45
  Fallback models use a 1,048,576-token context window, up to 256K output tokens, image input, and reasoning levels `minimal`, `low`, `medium`, `high`, and `xhigh`.
87
46
 
88
47
  | id | pricing (input/output/cached) $/M |
89
48
  | --- | --- |
49
+ | `muse-spark-1.3` | 1.25 / 4.25 / 0.15 |
50
+ | `muse-spark-1.3-contributor` | 0.10 / 0.20 / 0.002 |
90
51
  | `muse-spark-1.2` | 1.25 / 4.25 / 0.15 |
91
52
  | `muse-spark-1.2-contributor` | 0.10 / 0.20 / 0.002 |
92
53
  | `muse-spark-1.1` | 1.25 / 4.25 / 0.15 |
93
54
 
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).
55
+ > **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
56
 
96
57
  To scope Pi's model picker to Meta models:
97
58
 
@@ -122,29 +83,15 @@ catalog.
122
83
 
123
84
  ```bash
124
85
  pi --list-models meta
125
- pi -p --provider meta --model muse-spark-1.2 "Reply exactly: META_OK"
86
+ pi -p --provider meta --model muse-spark-1.3 "Reply exactly: META_OK"
126
87
  bun run typecheck
127
88
  bun test
128
89
  ```
129
90
 
130
- ## Publish to npm and pi.dev
131
-
132
- 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.
133
-
134
- Before the first automated release, configure npm trusted publishing for `pi-meta-oauth` with:
135
-
136
- - GitHub owner: `BlockedPath`
137
- - Repository: `pi-meta-oauth`
138
- - Workflow filename: `publish.yml`
139
- - Environment: leave blank
140
- - Allowed action: `npm publish`
141
-
142
- GitHub Packages uses the workflow's short-lived `GITHUB_TOKEN`. Its npm registry defaults new packages to private visibility; after the first publish, open the package settings on GitHub and change its visibility to **Public** if desired.
143
-
144
- Bump `package.json`, commit the change, create a `v<version>` tag, and push the commit and tag. The workflow verifies that the tag matches the package version, runs typechecking and tests, publishes to both registries, and creates the GitHub Release. Re-running a partially completed workflow is safe when either registry reports that the same commit was already published.
145
-
146
- Users can then update with:
91
+ `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):
147
92
 
148
93
  ```bash
149
- pi update npm:pi-meta-oauth
94
+ bun test tests/meta-cache.test.ts
95
+ # or, if you are not logged in:
96
+ PI_META_LIVE_API_KEY='LLM|...' bun test tests/meta-cache.test.ts
150
97
  ```
@@ -16,7 +16,6 @@ export const META_API_BASE_URL = "https://api.meta.ai/v1";
16
16
  export const META_MODEL_CATALOG_URL = "https://api.meta.ai/v1/models";
17
17
  export const META_AUTH_BASE_URL = "https://auth.meta.com";
18
18
  export const META_CLIENT_ID = "1031625952748946";
19
- export const META_FILES_URL = `${META_API_BASE_URL}/files`;
20
19
  const META_ENV_VAR = "META_API_KEY";
21
20
 
22
21
  const DEVICE_AUTHORIZATION_URL = `${META_AUTH_BASE_URL}/oidc/device/authorization/`;
@@ -77,11 +76,45 @@ interface MetaCatalogModel {
77
76
  };
78
77
  }
79
78
 
80
- // Media capabilities kept as "text"+"image" for pi-ai type compat (pi-ai 0.83/0.84 only allows those),
81
- // with "video"/"audio" advertised via cast so future pi-ai that expands the union picks them up.
82
- // See media.ts for the before_provider_request rewrite that makes @video/@audio work today
83
- // despite UserMessage.content being string | (TextContent | ImageContent)[] .
84
79
  const FALLBACK_MODELS: MetaProviderModel[] = [
80
+ {
81
+ id: "muse-spark-1.3",
82
+ name: "Muse Spark 1.3",
83
+ reasoning: true,
84
+ thinkingLevelMap: {
85
+ off: null,
86
+ minimal: "minimal",
87
+ low: "low",
88
+ medium: "medium",
89
+ high: "high",
90
+ xhigh: "xhigh",
91
+ max: null,
92
+ },
93
+ input: ["text", "image"],
94
+ cost: { input: 1.25, output: 4.25, cacheRead: 0.15, cacheWrite: 0 },
95
+ contextWindow: 1_048_576,
96
+ maxTokens: 256_000,
97
+ compat: { supportsReasoningEffort: true, supportsToolSearch: true },
98
+ },
99
+ {
100
+ id: "muse-spark-1.3-contributor",
101
+ name: "Muse Spark 1.3 Contributor",
102
+ reasoning: true,
103
+ thinkingLevelMap: {
104
+ off: null,
105
+ minimal: "minimal",
106
+ low: "low",
107
+ medium: "medium",
108
+ high: "high",
109
+ xhigh: "xhigh",
110
+ max: null,
111
+ },
112
+ input: ["text", "image"],
113
+ cost: { input: 0.1, output: 0.2, cacheRead: 0.002, cacheWrite: 0 },
114
+ contextWindow: 1_048_576,
115
+ maxTokens: 256_000,
116
+ compat: { supportsReasoningEffort: true, supportsToolSearch: true },
117
+ },
85
118
  {
86
119
  id: "muse-spark-1.2",
87
120
  name: "Muse Spark 1.2",
@@ -95,12 +128,7 @@ const FALLBACK_MODELS: MetaProviderModel[] = [
95
128
  xhigh: "xhigh",
96
129
  max: null,
97
130
  },
98
- input: [
99
- "text",
100
- "image",
101
- "video",
102
- "audio",
103
- ] as unknown as MetaProviderModel["input"],
131
+ input: ["text", "image"],
104
132
  cost: { input: 1.25, output: 4.25, cacheRead: 0.15, cacheWrite: 0 },
105
133
  contextWindow: 1_048_576,
106
134
  maxTokens: 256_000,
@@ -119,12 +147,7 @@ const FALLBACK_MODELS: MetaProviderModel[] = [
119
147
  xhigh: "xhigh",
120
148
  max: null,
121
149
  },
122
- input: [
123
- "text",
124
- "image",
125
- "video",
126
- "audio",
127
- ] as unknown as MetaProviderModel["input"],
150
+ input: ["text", "image"],
128
151
  cost: { input: 0.1, output: 0.2, cacheRead: 0.002, cacheWrite: 0 },
129
152
  contextWindow: 1_048_576,
130
153
  maxTokens: 256_000,
@@ -143,12 +166,7 @@ const FALLBACK_MODELS: MetaProviderModel[] = [
143
166
  xhigh: "xhigh",
144
167
  max: null,
145
168
  },
146
- input: [
147
- "text",
148
- "image",
149
- "video",
150
- "audio",
151
- ] as unknown as MetaProviderModel["input"],
169
+ input: ["text", "image"],
152
170
  cost: { input: 1.25, output: 4.25, cacheRead: 0.15, cacheWrite: 0 },
153
171
  contextWindow: 1_048_576,
154
172
  maxTokens: 256_000,
@@ -336,8 +354,7 @@ export async function refreshMetaToken(
336
354
  );
337
355
  // Pi 0.83 calls refreshToken(credentials). Pi 0.84 passes AbortSignal as
338
356
  // the second argument. Tests inject a fetch mock in that slot.
339
- const fetchImpl =
340
- typeof fetchOrSignal === "function" ? fetchOrSignal : fetch;
357
+ const fetchImpl = typeof fetchOrSignal === "function" ? fetchOrSignal : fetch;
341
358
  const signal = isAbortSignal(fetchOrSignal) ? fetchOrSignal : undefined;
342
359
  if (signal?.aborted) {
343
360
  throw new Error("Meta token refresh was cancelled");
@@ -376,19 +393,10 @@ function modalitiesToInput(
376
393
  modalities: string[] | undefined,
377
394
  fallback: MetaProviderModel["input"] | undefined,
378
395
  ): MetaProviderModel["input"] {
379
- if (!modalities)
380
- return fallback ?? (["text"] as unknown as MetaProviderModel["input"]);
381
- const input: string[] = ["text"];
396
+ if (!modalities) return fallback ?? ["text"];
397
+ const input: MetaProviderModel["input"] = ["text"];
382
398
  if (modalities.includes("image")) input.push("image");
383
- // Advertised via cast until pi-ai expands Model.input union beyond text|image.
384
- // The before_provider_request hook in media.ts rewrites these to typed media blocks at request time.
385
- if (modalities.includes("video")) input.push("video");
386
- if (modalities.includes("audio")) input.push("audio");
387
- // Some catalogs expose "document"/"pdf" as a modality for PDF handling.
388
- if (modalities.includes("document") || modalities.includes("pdf")) {
389
- if (!input.includes("image")) input.push("image"); // PDFs count toward image budget
390
- }
391
- return input as unknown as MetaProviderModel["input"];
399
+ return input;
392
400
  }
393
401
 
394
402
  export function toProviderModels(
@@ -521,6 +529,8 @@ export async function refreshMetaModels(
521
529
  context: RefreshModelsContext,
522
530
  fetchImpl: Fetch = fetch,
523
531
  ): Promise<MetaProviderModel[]> {
532
+ // SAFETY: CompatibleRefreshContext is the union of the Pi 0.83 and 0.84
533
+ // refresh-context fields that this adapter probes defensively at runtime.
524
534
  const compatibleContext = context as unknown as CompatibleRefreshContext;
525
535
  if (!context.allowNetwork || context.signal?.aborted) {
526
536
  const cached = await cachedMetaModels(compatibleContext);
@@ -582,6 +592,40 @@ export function metaFallbackCost(
582
592
  return FALLBACK_MODELS.find((model) => model.id === modelId)?.cost;
583
593
  }
584
594
 
595
+ /** Meta prompt-cache opt-in. Measured 0% hits on /chat/completions vs 93–99% on /responses with 24h. */
596
+ export const META_PROMPT_CACHE_RETENTION = "24h";
597
+
598
+ function asRecord(value: unknown): Record<string, unknown> | undefined {
599
+ return value !== null && typeof value === "object" && !Array.isArray(value)
600
+ ? (value as Record<string, unknown>)
601
+ : undefined;
602
+ }
603
+
604
+ /**
605
+ * Hermes-equivalent Responses hints for api.meta.ai:
606
+ * setdefault `prompt_cache_retention: 24h`, and drop `reasoning.effort: none`
607
+ * because Meta 400s on it.
608
+ */
609
+ export function applyMetaResponsesCacheHints(
610
+ payload: unknown,
611
+ ): Record<string, unknown> | undefined {
612
+ const body = asRecord(payload);
613
+ if (!body) return undefined;
614
+ if (body.prompt_cache_retention === undefined) {
615
+ body.prompt_cache_retention = META_PROMPT_CACHE_RETENTION;
616
+ }
617
+ const reasoning = asRecord(body.reasoning);
618
+ if (
619
+ reasoning &&
620
+ (reasoning.effort === "none" ||
621
+ reasoning.effort === undefined ||
622
+ reasoning.effort === null)
623
+ ) {
624
+ delete body.reasoning;
625
+ }
626
+ return body;
627
+ }
628
+
585
629
  export function createMetaProviderConfig(): ProviderConfig {
586
630
  return {
587
631
  name: "Meta Model API",
@@ -614,4 +658,8 @@ export default function metaOAuthProvider(pi: ExtensionAPI): void {
614
658
  process.env["MODEL_API_KEY"] = process.env[META_ENV_VAR];
615
659
  }
616
660
  pi.registerProvider(META_PROVIDER_ID, createMetaProviderConfig());
661
+ pi.on("before_provider_request", (event, ctx) => {
662
+ if (ctx.model?.provider !== META_PROVIDER_ID) return undefined;
663
+ return applyMetaResponsesCacheHints(event.payload);
664
+ });
617
665
  }
package/package.json CHANGED
@@ -1,15 +1,13 @@
1
1
  {
2
2
  "name": "pi-meta-oauth",
3
- "version": "0.4.4",
4
- "description": "Meta Model API OAuth provider and Muse-style voice dictation for pi",
3
+ "version": "0.6.0",
4
+ "description": "Meta Model API OAuth provider for pi",
5
5
  "keywords": [
6
6
  "pi-package",
7
7
  "meta",
8
8
  "muse-spark",
9
9
  "oauth",
10
- "provider",
11
- "voice",
12
- "dictation"
10
+ "provider"
13
11
  ],
14
12
  "license": "MIT",
15
13
  "type": "module",
@@ -28,22 +26,17 @@
28
26
  },
29
27
  "pi": {
30
28
  "extensions": [
31
- "./extensions/meta.ts",
32
- "./extensions/media.ts",
33
- "./extensions/voice.ts"
29
+ "./extensions/meta.ts"
34
30
  ],
35
31
  "image": "https://raw.githubusercontent.com/BlockedPath/pi-meta-oauth/main/preview.png"
36
32
  },
37
33
  "peerDependencies": {
38
34
  "@earendil-works/pi-ai": ">=0.83.0 <0.85.0",
39
- "@earendil-works/pi-coding-agent": ">=0.83.0 <0.85.0",
40
- "@earendil-works/pi-tui": ">=0.83.0 <0.85.0",
41
- "typebox": "*"
35
+ "@earendil-works/pi-coding-agent": ">=0.83.0 <0.85.0"
42
36
  },
43
37
  "devDependencies": {
44
38
  "@earendil-works/pi-ai": "0.83.0",
45
39
  "@earendil-works/pi-coding-agent": "0.83.0",
46
- "@earendil-works/pi-tui": "0.83.0",
47
40
  "bun-types": "latest",
48
41
  "typescript": "^5.9.0"
49
42
  },
@@ -1,131 +0,0 @@
1
- import { basename } from "node:path";
2
- import { readFile } from "node:fs/promises";
3
- import { statSync } from "node:fs";
4
- import { META_FILES_URL } from "../meta.ts";
5
- import {
6
- AUTOMATIC_UPLOAD_EXPIRY_SECONDS,
7
- FILES_API_LIMIT_BYTES,
8
- } from "./limits.ts";
9
- import { filenameForMime, mimeForPath } from "./mime.ts";
10
-
11
- export interface UploadResult {
12
- id: string;
13
- filename: string;
14
- bytes: number;
15
- purpose: string;
16
- status: string;
17
- expires_at?: number;
18
- }
19
-
20
- function uploadErrorDetail(body: Record<string, unknown>, bodyText: string): string {
21
- if (typeof body.error === "string") return body.error;
22
- if (typeof body.message === "string") return body.message;
23
- if (typeof body.error_description === "string") return body.error_description;
24
- return bodyText.slice(0, 500);
25
- }
26
-
27
- export async function uploadMetaFile(
28
- apiKey: string,
29
- filePath: string,
30
- expiresAfter?: { anchor: "created_at"; seconds: number },
31
- signal?: AbortSignal,
32
- ): Promise<UploadResult> {
33
- const size = statSync(filePath).size;
34
- if (size > FILES_API_LIMIT_BYTES) {
35
- throw new Error(
36
- `File too large for Files API (${size} bytes > 1 GiB)`,
37
- );
38
- }
39
- const data = await readFile(filePath, { signal });
40
- const mime = mimeForPath(filePath) ?? "application/octet-stream";
41
- const blob = new Blob([data], { type: mime });
42
- const form = new FormData();
43
- form.append("file", blob, basename(filePath));
44
- form.append("purpose", "user_data");
45
- if (expiresAfter) {
46
- form.append("expires_after[anchor]", expiresAfter.anchor);
47
- form.append("expires_after[seconds]", String(expiresAfter.seconds));
48
- }
49
- const res = await fetch(META_FILES_URL, {
50
- method: "POST",
51
- headers: { Authorization: `Bearer ${apiKey}` },
52
- body: form,
53
- signal,
54
- });
55
- const bodyText = await res.text();
56
- let body: Record<string, unknown> = {};
57
- try {
58
- body = bodyText ? (JSON.parse(bodyText) as Record<string, unknown>) : {};
59
- } catch {
60
- // Non-JSON body — keep empty and use raw text for error detail
61
- }
62
- if (!res.ok) {
63
- const detail = uploadErrorDetail(body, bodyText);
64
- if (res.status === 400 && detail.includes("storage")) {
65
- throw new Error(
66
- `Meta Files storage limit reached (100 GiB/team). Delete old files via DELETE /v1/files/{id}. Detail: ${detail}`,
67
- );
68
- }
69
- throw new Error(
70
- `Meta Files upload failed (HTTP ${res.status}): ${detail || res.statusText}`,
71
- );
72
- }
73
- const id =
74
- typeof body.id === "string"
75
- ? body.id
76
- : typeof body.file_id === "string"
77
- ? body.file_id
78
- : undefined;
79
- if (!id) {
80
- throw new Error(
81
- `Meta Files upload returned no id: ${bodyText.slice(0, 500)}`,
82
- );
83
- }
84
- return {
85
- id,
86
- filename:
87
- typeof body.filename === "string" ? body.filename : basename(filePath),
88
- bytes: typeof body.bytes === "number" ? body.bytes : data.byteLength,
89
- purpose: typeof body.purpose === "string" ? body.purpose : "user_data",
90
- status: typeof body.status === "string" ? body.status : "uploaded",
91
- expires_at:
92
- typeof body.expires_at === "number" ? body.expires_at : undefined,
93
- };
94
- }
95
-
96
- export async function uploadInlineMedia(
97
- apiKey: string,
98
- base64: string,
99
- mime: string,
100
- filename: string,
101
- signal?: AbortSignal,
102
- ): Promise<string> {
103
- const blob = new Blob([Buffer.from(base64, "base64")], { type: mime });
104
- const form = new FormData();
105
- form.append("file", blob, filename || filenameForMime(mime));
106
- form.append("purpose", "user_data");
107
- form.append("expires_after[anchor]", "created_at");
108
- form.append("expires_after[seconds]", String(AUTOMATIC_UPLOAD_EXPIRY_SECONDS));
109
- const res = await fetch(META_FILES_URL, {
110
- method: "POST",
111
- headers: { Authorization: `Bearer ${apiKey}` },
112
- body: form,
113
- signal,
114
- });
115
- const text = await res.text();
116
- if (!res.ok) {
117
- throw new Error(
118
- `Files upload for large inline media failed: ${text.slice(0, 500)}`,
119
- );
120
- }
121
- let json: Record<string, unknown>;
122
- try {
123
- json = JSON.parse(text) as Record<string, unknown>;
124
- } catch (error) {
125
- throw new Error(`Files upload returned invalid JSON: ${String(error)}`);
126
- }
127
- if (typeof json.id !== "string" || !json.id) {
128
- throw new Error(`Files upload returned no id: ${text.slice(0, 500)}`);
129
- }
130
- return json.id;
131
- }
@@ -1,12 +0,0 @@
1
- export const INLINE_LIMIT_BYTES = 50_000_000;
2
- export const FILES_API_LIMIT_BYTES = 1_073_741_824;
3
- // Meta Responses `store=true` (default) has ~20 MB persistence limit even when
4
- // inline limit is 50 MB — a 24 MB base64 payload triggers HTTP 413
5
- // "payload_too_large … with `store=true`". Keep inline only for small files
6
- // and force Files API or `store:false` above this threshold.
7
- export const STORE_SAFE_INLINE_BYTES = 15_000_000;
8
- export const AUTOMATIC_UPLOAD_EXPIRY_SECONDS = 24 * 60 * 60;
9
- export const EXPLICIT_UPLOAD_EXPIRY_SECONDS = 7 * 24 * 60 * 60;
10
- export const MIN_UPLOAD_EXPIRY_SECONDS = 3_600;
11
- export const MAX_UPLOAD_EXPIRY_SECONDS = 2_592_000;
12
- export const MAX_ANALYSIS_SOURCES = 50;