pi-meta-oauth 0.5.0 → 0.6.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +6 -70
- package/extensions/meta.ts +21 -52
- package/package.json +5 -12
- package/extensions/media/files.ts +0 -131
- package/extensions/media/limits.ts +0 -12
- package/extensions/media/mime.ts +0 -178
- package/extensions/media/payload.ts +0 -303
- package/extensions/media/responses.ts +0 -192
- package/extensions/media-output.ts +0 -127
- package/extensions/media.ts +0 -990
- package/extensions/voice/Entitlements.plist +0 -8
- package/extensions/voice/Info.plist +0 -22
- package/extensions/voice/asr.ts +0 -131
- package/extensions/voice/helpers.ts +0 -243
- package/extensions/voice/macos-audio.swift +0 -172
- package/extensions/voice/windows-audio.cs +0 -281
- package/extensions/voice/windows-audio.ps1 +0 -195
- package/extensions/voice.ts +0 -667
package/README.md
CHANGED
|
@@ -4,25 +4,24 @@
|
|
|
4
4
|
|
|
5
5
|
<!-- markdownlint-disable-next-line MD013 -->
|
|
6
6
|

|
|
7
|
-
[](https://opensource.org/licenses/MIT) [](https://github.com/BlockedPath/pi-meta-oauth/stargazers) [](https://github.com/BlockedPath/pi-meta-oauth/commits/main) [](https://github.com/BlockedPath/pi-meta-oauth/issues) [](https://github.com/BlockedPath/pi-meta-oauth/pulls) [](https://www.conventionalcommits.org/en/v1.0.0/) [](https://www.typescriptlang.org/) [](https://github.com/BlockedPath/pi-meta-oauth/actions/workflows/ci.yml) [](https://pi.dev)
|
|
8
8
|
|
|
9
|
-
Meta Model API OAuth
|
|
9
|
+
Meta Model API OAuth for [pi](https://pi.dev).
|
|
10
10
|
|
|
11
11
|
- Use Muse Spark models through Pi's `openai-responses` provider (not `/chat/completions` — Muse prompt cache is ~0% there)
|
|
12
12
|
- Send `prompt_cache_retention: "24h"` on Meta Responses requests unless the payload already set a retention
|
|
13
13
|
- Device authorization against `https://auth.meta.com`
|
|
14
14
|
- Model API-key minting through `POST https://api.meta.ai/muse-code/key`
|
|
15
15
|
- Dynamic Muse model catalog from `GET https://api.meta.ai/v1/models`
|
|
16
|
-
- Toggle-based Meta voice dictation on macOS and Windows with a live green input meter
|
|
17
16
|
|
|
18
17
|
## Install
|
|
19
18
|
|
|
20
19
|
```bash
|
|
21
|
-
#
|
|
22
|
-
pi install /
|
|
20
|
+
# OAuth-only branch
|
|
21
|
+
pi install git:github.com/BlockedPath/pi-meta-oauth@meta-oauth-only
|
|
23
22
|
|
|
24
|
-
#
|
|
25
|
-
pi install
|
|
23
|
+
# Or from a local checkout
|
|
24
|
+
pi install /absolute/path/to/pi-meta-oauth
|
|
26
25
|
|
|
27
26
|
pi --list-models meta
|
|
28
27
|
```
|
|
@@ -41,47 +40,6 @@ Pi displays a device code, opens the Meta authorization flow, and mints a Model
|
|
|
41
40
|
|
|
42
41
|
The access key is re-minted daily.
|
|
43
42
|
|
|
44
|
-
## Voice input
|
|
45
|
-
|
|
46
|
-
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.
|
|
47
|
-
|
|
48
|
-
1. Press **Alt+V** once to start recording.
|
|
49
|
-
2. Speak while the green microphone meter is visible in Pi's status bar.
|
|
50
|
-
3. Press **Alt+V** again to stop.
|
|
51
|
-
4. The transcript is inserted into Pi's editor for review before submission.
|
|
52
|
-
|
|
53
|
-
Commands:
|
|
54
|
-
|
|
55
|
-
- `/voice` — show voice status and privacy behavior
|
|
56
|
-
- `/voice-on` — enable the Alt+V shortcut
|
|
57
|
-
- `/voice-off` — disable voice input
|
|
58
|
-
|
|
59
|
-
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.
|
|
60
|
-
|
|
61
|
-
**macOS:** uses `AVFoundation` via a Swift helper compiled with `xcrun swiftc` (first recording triggers the system microphone permission prompt).
|
|
62
|
-
|
|
63
|
-
**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.
|
|
64
|
-
|
|
65
|
-
Optional overrides (the `PI_META_*` name takes precedence):
|
|
66
|
-
|
|
67
|
-
- `PI_META_VOICE_ASR_ENDPOINT` (legacy alias: `MUSE_VOICE_ASR_ENDPOINT`)
|
|
68
|
-
- `PI_META_VOICE_ASR_MODEL` (legacy alias: `MUSE_VOICE_ASR_MODEL`)
|
|
69
|
-
|
|
70
|
-
## Media tools
|
|
71
|
-
|
|
72
|
-
Text-only Pi models can delegate media inspection to Muse Spark without switching models:
|
|
73
|
-
|
|
74
|
-
- `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.
|
|
75
|
-
- `meta_describe_video` analyzes MP4 visuals and embedded audio.
|
|
76
|
-
- `meta_transcribe_audio` transcribes MP3 or WAV speech.
|
|
77
|
-
- `meta_upload_file` uploads a large or reusable file and returns a Meta `file_id`.
|
|
78
|
-
|
|
79
|
-
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`.
|
|
80
|
-
|
|
81
|
-
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.
|
|
82
|
-
|
|
83
|
-
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.
|
|
84
|
-
|
|
85
43
|
## Models
|
|
86
44
|
|
|
87
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`.
|
|
@@ -137,25 +95,3 @@ bun test tests/meta-cache.test.ts
|
|
|
137
95
|
# or, if you are not logged in:
|
|
138
96
|
PI_META_LIVE_API_KEY='LLM|...' bun test tests/meta-cache.test.ts
|
|
139
97
|
```
|
|
140
|
-
|
|
141
|
-
## Publish to npm and pi.dev
|
|
142
|
-
|
|
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.
|
|
144
|
-
|
|
145
|
-
Before the first automated release, configure npm trusted publishing for `pi-meta-oauth` with:
|
|
146
|
-
|
|
147
|
-
- GitHub owner: `BlockedPath`
|
|
148
|
-
- Repository: `pi-meta-oauth`
|
|
149
|
-
- Workflow filename: `publish.yml`
|
|
150
|
-
- Environment: leave blank
|
|
151
|
-
- Allowed action: `npm publish`
|
|
152
|
-
|
|
153
|
-
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.
|
|
154
|
-
|
|
155
|
-
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.
|
|
156
|
-
|
|
157
|
-
Users can then update with:
|
|
158
|
-
|
|
159
|
-
```bash
|
|
160
|
-
pi update npm:pi-meta-oauth
|
|
161
|
-
```
|
package/extensions/meta.ts
CHANGED
|
@@ -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,10 +76,6 @@ 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[] = [
|
|
85
80
|
{
|
|
86
81
|
id: "muse-spark-1.3",
|
|
@@ -95,13 +90,7 @@ const FALLBACK_MODELS: MetaProviderModel[] = [
|
|
|
95
90
|
xhigh: "xhigh",
|
|
96
91
|
max: null,
|
|
97
92
|
},
|
|
98
|
-
|
|
99
|
-
input: [
|
|
100
|
-
"text",
|
|
101
|
-
"image",
|
|
102
|
-
"video",
|
|
103
|
-
"audio",
|
|
104
|
-
] as unknown as MetaProviderModel["input"],
|
|
93
|
+
input: ["text", "image"],
|
|
105
94
|
cost: { input: 1.25, output: 4.25, cacheRead: 0.15, cacheWrite: 0 },
|
|
106
95
|
contextWindow: 1_048_576,
|
|
107
96
|
maxTokens: 256_000,
|
|
@@ -120,13 +109,7 @@ const FALLBACK_MODELS: MetaProviderModel[] = [
|
|
|
120
109
|
xhigh: "xhigh",
|
|
121
110
|
max: null,
|
|
122
111
|
},
|
|
123
|
-
|
|
124
|
-
input: [
|
|
125
|
-
"text",
|
|
126
|
-
"image",
|
|
127
|
-
"video",
|
|
128
|
-
"audio",
|
|
129
|
-
] as unknown as MetaProviderModel["input"],
|
|
112
|
+
input: ["text", "image"],
|
|
130
113
|
cost: { input: 0.1, output: 0.2, cacheRead: 0.002, cacheWrite: 0 },
|
|
131
114
|
contextWindow: 1_048_576,
|
|
132
115
|
maxTokens: 256_000,
|
|
@@ -145,12 +128,7 @@ const FALLBACK_MODELS: MetaProviderModel[] = [
|
|
|
145
128
|
xhigh: "xhigh",
|
|
146
129
|
max: null,
|
|
147
130
|
},
|
|
148
|
-
input: [
|
|
149
|
-
"text",
|
|
150
|
-
"image",
|
|
151
|
-
"video",
|
|
152
|
-
"audio",
|
|
153
|
-
] as unknown as MetaProviderModel["input"],
|
|
131
|
+
input: ["text", "image"],
|
|
154
132
|
cost: { input: 1.25, output: 4.25, cacheRead: 0.15, cacheWrite: 0 },
|
|
155
133
|
contextWindow: 1_048_576,
|
|
156
134
|
maxTokens: 256_000,
|
|
@@ -169,12 +147,7 @@ const FALLBACK_MODELS: MetaProviderModel[] = [
|
|
|
169
147
|
xhigh: "xhigh",
|
|
170
148
|
max: null,
|
|
171
149
|
},
|
|
172
|
-
input: [
|
|
173
|
-
"text",
|
|
174
|
-
"image",
|
|
175
|
-
"video",
|
|
176
|
-
"audio",
|
|
177
|
-
] as unknown as MetaProviderModel["input"],
|
|
150
|
+
input: ["text", "image"],
|
|
178
151
|
cost: { input: 0.1, output: 0.2, cacheRead: 0.002, cacheWrite: 0 },
|
|
179
152
|
contextWindow: 1_048_576,
|
|
180
153
|
maxTokens: 256_000,
|
|
@@ -193,12 +166,7 @@ const FALLBACK_MODELS: MetaProviderModel[] = [
|
|
|
193
166
|
xhigh: "xhigh",
|
|
194
167
|
max: null,
|
|
195
168
|
},
|
|
196
|
-
input: [
|
|
197
|
-
"text",
|
|
198
|
-
"image",
|
|
199
|
-
"video",
|
|
200
|
-
"audio",
|
|
201
|
-
] as unknown as MetaProviderModel["input"],
|
|
169
|
+
input: ["text", "image"],
|
|
202
170
|
cost: { input: 1.25, output: 4.25, cacheRead: 0.15, cacheWrite: 0 },
|
|
203
171
|
contextWindow: 1_048_576,
|
|
204
172
|
maxTokens: 256_000,
|
|
@@ -281,8 +249,14 @@ export async function mintMetaApiKey(
|
|
|
281
249
|
const body = (await responseBody(response)) as MintResponse &
|
|
282
250
|
Record<string, unknown>;
|
|
283
251
|
if (!response.ok) {
|
|
252
|
+
const detail = errorDetail(body);
|
|
253
|
+
if (response.status === 401 || response.status === 403) {
|
|
254
|
+
throw new Error(
|
|
255
|
+
`Meta session expired (HTTP ${response.status}); run /login meta again${detail ? `: ${detail}` : ""}`,
|
|
256
|
+
);
|
|
257
|
+
}
|
|
284
258
|
throw new Error(
|
|
285
|
-
`Meta API-key mint failed (HTTP ${response.status})${
|
|
259
|
+
`Meta API-key mint failed (HTTP ${response.status})${detail ? `: ${detail}` : ""}`,
|
|
286
260
|
);
|
|
287
261
|
}
|
|
288
262
|
if (typeof body.api_key !== "string" || !body.api_key) {
|
|
@@ -425,19 +399,10 @@ function modalitiesToInput(
|
|
|
425
399
|
modalities: string[] | undefined,
|
|
426
400
|
fallback: MetaProviderModel["input"] | undefined,
|
|
427
401
|
): MetaProviderModel["input"] {
|
|
428
|
-
if (!modalities)
|
|
429
|
-
|
|
430
|
-
const input: string[] = ["text"];
|
|
402
|
+
if (!modalities) return fallback ?? ["text"];
|
|
403
|
+
const input: MetaProviderModel["input"] = ["text"];
|
|
431
404
|
if (modalities.includes("image")) input.push("image");
|
|
432
|
-
|
|
433
|
-
// The before_provider_request hook in media.ts rewrites these to typed media blocks at request time.
|
|
434
|
-
if (modalities.includes("video")) input.push("video");
|
|
435
|
-
if (modalities.includes("audio")) input.push("audio");
|
|
436
|
-
// Some catalogs expose "document"/"pdf" as a modality for PDF handling.
|
|
437
|
-
if (modalities.includes("document") || modalities.includes("pdf")) {
|
|
438
|
-
if (!input.includes("image")) input.push("image"); // PDFs count toward image budget
|
|
439
|
-
}
|
|
440
|
-
return input as unknown as MetaProviderModel["input"];
|
|
405
|
+
return input;
|
|
441
406
|
}
|
|
442
407
|
|
|
443
408
|
export function toProviderModels(
|
|
@@ -570,6 +535,8 @@ export async function refreshMetaModels(
|
|
|
570
535
|
context: RefreshModelsContext,
|
|
571
536
|
fetchImpl: Fetch = fetch,
|
|
572
537
|
): Promise<MetaProviderModel[]> {
|
|
538
|
+
// SAFETY: CompatibleRefreshContext is the union of the Pi 0.83 and 0.84
|
|
539
|
+
// refresh-context fields that this adapter probes defensively at runtime.
|
|
573
540
|
const compatibleContext = context as unknown as CompatibleRefreshContext;
|
|
574
541
|
if (!context.allowNetwork || context.signal?.aborted) {
|
|
575
542
|
const cached = await cachedMetaModels(compatibleContext);
|
|
@@ -645,9 +612,11 @@ function asRecord(value: unknown): Record<string, unknown> | undefined {
|
|
|
645
612
|
* setdefault `prompt_cache_retention: 24h`, and drop `reasoning.effort: none`
|
|
646
613
|
* because Meta 400s on it.
|
|
647
614
|
*/
|
|
648
|
-
export function applyMetaResponsesCacheHints(
|
|
615
|
+
export function applyMetaResponsesCacheHints(
|
|
616
|
+
payload: unknown,
|
|
617
|
+
): Record<string, unknown> | undefined {
|
|
649
618
|
const body = asRecord(payload);
|
|
650
|
-
if (!body) return
|
|
619
|
+
if (!body) return undefined;
|
|
651
620
|
if (body.prompt_cache_retention === undefined) {
|
|
652
621
|
body.prompt_cache_retention = META_PROMPT_CACHE_RETENTION;
|
|
653
622
|
}
|
package/package.json
CHANGED
|
@@ -1,15 +1,13 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-meta-oauth",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "Meta Model API OAuth provider
|
|
3
|
+
"version": "0.6.1",
|
|
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;
|
package/extensions/media/mime.ts
DELETED
|
@@ -1,178 +0,0 @@
|
|
|
1
|
-
import { extname } from "node:path";
|
|
2
|
-
|
|
3
|
-
export interface ResponsesContentBlock {
|
|
4
|
-
type: string;
|
|
5
|
-
[key: string]: unknown;
|
|
6
|
-
}
|
|
7
|
-
|
|
8
|
-
type SupportedMime =
|
|
9
|
-
| "video/mp4"
|
|
10
|
-
| "audio/mpeg"
|
|
11
|
-
| "audio/wav"
|
|
12
|
-
| "application/pdf"
|
|
13
|
-
| "image/png"
|
|
14
|
-
| "image/jpeg"
|
|
15
|
-
| "image/gif"
|
|
16
|
-
| "image/webp"
|
|
17
|
-
| "image/x-icon"
|
|
18
|
-
| "text/plain"
|
|
19
|
-
| "application/json"
|
|
20
|
-
| "application/jsonl";
|
|
21
|
-
|
|
22
|
-
const EXT_TO_MIME: Record<string, SupportedMime> = {
|
|
23
|
-
".mp4": "video/mp4",
|
|
24
|
-
".m4v": "video/mp4",
|
|
25
|
-
".mp3": "audio/mpeg",
|
|
26
|
-
".wav": "audio/wav",
|
|
27
|
-
".pdf": "application/pdf",
|
|
28
|
-
".png": "image/png",
|
|
29
|
-
".jpg": "image/jpeg",
|
|
30
|
-
".jpeg": "image/jpeg",
|
|
31
|
-
".gif": "image/gif",
|
|
32
|
-
".webp": "image/webp",
|
|
33
|
-
".ico": "image/x-icon",
|
|
34
|
-
".txt": "text/plain",
|
|
35
|
-
".json": "application/json",
|
|
36
|
-
".jsonl": "application/jsonl",
|
|
37
|
-
};
|
|
38
|
-
|
|
39
|
-
export function mimeForPath(path: string): SupportedMime | undefined {
|
|
40
|
-
const ext = extname(path).toLowerCase();
|
|
41
|
-
return EXT_TO_MIME[ext];
|
|
42
|
-
}
|
|
43
|
-
|
|
44
|
-
export function mimeForDataUrl(dataUrl: string): string | undefined {
|
|
45
|
-
const m = dataUrl.match(/^data:([^;,\s]+)[;,]/);
|
|
46
|
-
return m?.[1]?.toLowerCase();
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
export function isVideoMime(m: string | undefined): boolean {
|
|
50
|
-
return m === "video/mp4";
|
|
51
|
-
}
|
|
52
|
-
|
|
53
|
-
export function isAudioMime(m: string | undefined): boolean {
|
|
54
|
-
return (
|
|
55
|
-
m === "audio/mpeg" ||
|
|
56
|
-
m === "audio/mp3" ||
|
|
57
|
-
m === "audio/wav" ||
|
|
58
|
-
m === "audio/x-wav"
|
|
59
|
-
);
|
|
60
|
-
}
|
|
61
|
-
|
|
62
|
-
export function isPdfMime(m: string | undefined): boolean {
|
|
63
|
-
return m === "application/pdf";
|
|
64
|
-
}
|
|
65
|
-
|
|
66
|
-
export function isImageMime(m: string | undefined): boolean {
|
|
67
|
-
return typeof m === "string" && m.startsWith("image/");
|
|
68
|
-
}
|
|
69
|
-
|
|
70
|
-
export function filenameForMime(
|
|
71
|
-
mime: string | undefined,
|
|
72
|
-
fallback = "file.bin",
|
|
73
|
-
): string {
|
|
74
|
-
switch (mime) {
|
|
75
|
-
case "video/mp4":
|
|
76
|
-
return "video.mp4";
|
|
77
|
-
case "audio/mpeg":
|
|
78
|
-
case "audio/mp3":
|
|
79
|
-
return "audio.mp3";
|
|
80
|
-
case "audio/wav":
|
|
81
|
-
case "audio/x-wav":
|
|
82
|
-
return "audio.wav";
|
|
83
|
-
case "application/pdf":
|
|
84
|
-
return "document.pdf";
|
|
85
|
-
default:
|
|
86
|
-
return fallback;
|
|
87
|
-
}
|
|
88
|
-
}
|
|
89
|
-
|
|
90
|
-
export function audioFormatForMime(mime: string | undefined): "wav" | "mp3" {
|
|
91
|
-
return mime === "audio/wav" || mime === "audio/x-wav" ? "wav" : "mp3";
|
|
92
|
-
}
|
|
93
|
-
|
|
94
|
-
export function base64FromDataUrl(dataUrl: string): string {
|
|
95
|
-
const comma = dataUrl.indexOf(",");
|
|
96
|
-
return comma === -1 ? "" : dataUrl.slice(comma + 1);
|
|
97
|
-
}
|
|
98
|
-
|
|
99
|
-
export function base64ByteLength(base64: string): number {
|
|
100
|
-
const normalized = base64.replace(/\s/g, "");
|
|
101
|
-
if (!normalized) return 0;
|
|
102
|
-
const padding = normalized.endsWith("==")
|
|
103
|
-
? 2
|
|
104
|
-
: normalized.endsWith("=")
|
|
105
|
-
? 1
|
|
106
|
-
: 0;
|
|
107
|
-
return Math.max(0, Math.floor((normalized.length * 3) / 4) - padding);
|
|
108
|
-
}
|
|
109
|
-
|
|
110
|
-
export function isRemoteMediaSource(source: string): boolean {
|
|
111
|
-
return source.startsWith("https://") || source.startsWith("data:");
|
|
112
|
-
}
|
|
113
|
-
|
|
114
|
-
export function mediaPathFromUrl(url: string): string {
|
|
115
|
-
if (url.startsWith("data:")) return url;
|
|
116
|
-
try {
|
|
117
|
-
return new URL(url).pathname;
|
|
118
|
-
} catch {
|
|
119
|
-
const withoutHash = url.split("#")[0] ?? url;
|
|
120
|
-
return withoutHash.split("?")[0] ?? withoutHash;
|
|
121
|
-
}
|
|
122
|
-
}
|
|
123
|
-
|
|
124
|
-
export function mimeForSource(source: string): string | undefined {
|
|
125
|
-
if (source.startsWith("data:")) return mimeForDataUrl(source);
|
|
126
|
-
if (source.startsWith("https://")) {
|
|
127
|
-
try {
|
|
128
|
-
return mimeForPath(new URL(source).pathname);
|
|
129
|
-
} catch {
|
|
130
|
-
return undefined;
|
|
131
|
-
}
|
|
132
|
-
}
|
|
133
|
-
return mimeForPath(source);
|
|
134
|
-
}
|
|
135
|
-
|
|
136
|
-
/** Build a generic Meta Responses input_file block. */
|
|
137
|
-
export function inputFileFromSource(
|
|
138
|
-
source: string,
|
|
139
|
-
filename?: string,
|
|
140
|
-
): ResponsesContentBlock {
|
|
141
|
-
if (source.startsWith("https://")) {
|
|
142
|
-
return { type: "input_file", file_url: source };
|
|
143
|
-
}
|
|
144
|
-
if (source.startsWith("data:")) {
|
|
145
|
-
const mime = mimeForDataUrl(source);
|
|
146
|
-
return {
|
|
147
|
-
type: "input_file",
|
|
148
|
-
file_data: source,
|
|
149
|
-
filename: filename ?? filenameForMime(mime),
|
|
150
|
-
};
|
|
151
|
-
}
|
|
152
|
-
return { type: "input_file", file_id: source };
|
|
153
|
-
}
|
|
154
|
-
|
|
155
|
-
/** Build the typed content block required by Meta for each media kind. */
|
|
156
|
-
export function mediaInputFromSource(
|
|
157
|
-
source: string,
|
|
158
|
-
filename?: string,
|
|
159
|
-
expectedMime?: string,
|
|
160
|
-
): ResponsesContentBlock {
|
|
161
|
-
if (!source.startsWith("data:") && !source.startsWith("https://")) {
|
|
162
|
-
return { type: "input_file", file_id: source };
|
|
163
|
-
}
|
|
164
|
-
const mime = expectedMime ?? mimeForSource(source);
|
|
165
|
-
if (isVideoMime(mime)) {
|
|
166
|
-
return { type: "input_video", video_url: source };
|
|
167
|
-
}
|
|
168
|
-
if (isAudioMime(mime) && source.startsWith("data:")) {
|
|
169
|
-
return {
|
|
170
|
-
type: "input_audio",
|
|
171
|
-
input_audio: {
|
|
172
|
-
data: base64FromDataUrl(source),
|
|
173
|
-
format: audioFormatForMime(mime),
|
|
174
|
-
},
|
|
175
|
-
};
|
|
176
|
-
}
|
|
177
|
-
return inputFileFromSource(source, filename);
|
|
178
|
-
}
|