pi-meta-oauth 0.4.3 → 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`
@@ -61,10 +62,10 @@ Audio is captured only between the two Alt+V presses and streamed as 16 kHz mono
61
62
 
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.
63
64
 
64
- Optional overrides:
65
+ Optional overrides (the `PI_META_*` name takes precedence):
65
66
 
66
- - `PI_META_VOICE_ASR_ENDPOINT`
67
- - `PI_META_VOICE_ASR_MODEL`
67
+ - `PI_META_VOICE_ASR_ENDPOINT` (legacy alias: `MUSE_VOICE_ASR_ENDPOINT`)
68
+ - `PI_META_VOICE_ASR_MODEL` (legacy alias: `MUSE_VOICE_ASR_MODEL`)
68
69
 
69
70
  ## Media tools
70
71
 
@@ -87,10 +88,14 @@ 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
 
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).
98
+
94
99
  To scope Pi's model picker to Meta models:
95
100
 
96
101
  ```json
@@ -99,49 +104,40 @@ To scope Pi's model picker to Meta models:
99
104
 
100
105
  ### Making context windows visible to external tools
101
106
 
102
- Pi knows the Meta context window at runtime, but it does **not** persist
103
- catalogs for extension-registered providers to `~/.pi/agent/models-store.json`.
104
- Anything reading Pi's session data from outside the process therefore sees no
105
- window for `meta` models and can only show an absolute token count.
106
-
107
- This affects usage tooling such as
108
- [herdr-agent-usage](https://github.com/senna-lang/herdr-agent-usage), whose
109
- sidebar shows `⛁ 24k` instead of `⛁ 2% (24k)` for Muse panes.
110
-
111
- To publish the window, add an override to `~/.pi/agent/models.json`:
112
-
113
- ```json
114
- {
115
- "providers": {
116
- "meta": {
117
- "modelOverrides": {
118
- "muse-spark-1.2": { "contextWindow": 1007997 },
119
- "muse-spark-1.2-contributor": { "contextWindow": 1007997 }
120
- }
121
- }
122
- }
123
- }
124
- ```
125
-
126
- `1,007,997` is the effective limit reported for those two models by a cached
127
- Muse Code 0.1.0/R708.1 catalog snapshot observed on 2026-08-06. That snapshot
128
- did not include `muse-spark-1.1`, so no effective-limit override is claimed for
129
- 1.1 here. The bundled fallback in `extensions/meta.ts` uses the nominal
130
- `1048576`; use the lower value only when you want percentages to match that
131
- specific Muse snapshot.
132
-
133
- This only adds metadata — the provider itself is still registered by the
134
- extension, and `pi --list-models meta` is unchanged.
107
+ After a successful network model refresh, the extension persists the Meta
108
+ catalog to `~/.pi/agent/models-store.json`. External usage tools such as
109
+ [herdr-agent-usage](https://github.com/senna-lang/herdr-agent-usage) can then
110
+ show a percentage (for example, `⛁ 2% (24k)`) instead of only an absolute token
111
+ count. Pi writes the cache during interactive or RPC startup, and again after
112
+ `/login meta`. `pi --list-models meta` lists currently available models but does
113
+ not itself trigger a network catalog refresh. The cached catalog is also used
114
+ when Pi starts without network access.
115
+
116
+ The bundled fallback uses Meta's nominal `1,048,576`-token context window. A
117
+ cached Muse Code 0.1.0/R708.1 catalog observed on 2026-08-06 reported a lower
118
+ effective limit of `1,007,997` for `muse-spark-1.2` and
119
+ `muse-spark-1.2-contributor`. If you need percentages to match that specific
120
+ Muse snapshot, you can still set `contextWindow: 1007997` for those models in
121
+ `~/.pi/agent/models.json`; model overrides take precedence over the persisted
122
+ catalog.
135
123
 
136
124
  ## Verify
137
125
 
138
126
  ```bash
139
127
  pi --list-models meta
140
- 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"
141
129
  bun run typecheck
142
130
  bun test
143
131
  ```
144
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
+
145
141
  ## Publish to npm and pi.dev
146
142
 
147
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.
@@ -0,0 +1,131 @@
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
+ }
@@ -0,0 +1,12 @@
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;
@@ -0,0 +1,178 @@
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
+ }