@geocine/pi-meta-oauth 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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 BlockedPath
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,28 @@
1
+ # pi-meta-oauth
2
+
3
+ Meta Model API OAuth for [pi](https://pi.dev). Use Muse Spark models through Pi's `openai-responses` provider.
4
+
5
+ ## Install
6
+
7
+ ```bash
8
+ pi install git:github.com/geocine/pi-meta-oauth@geocine
9
+ pi --list-models meta
10
+ ```
11
+
12
+ ## Login
13
+
14
+ ```text
15
+ /login meta
16
+ ```
17
+
18
+ Pick browser login (device flow) or paste a Model API key. Device-flow keys are re-minted daily; pasted keys are stored as-is. Or skip login entirely with `META_API_KEY` / `MODEL_API_KEY`.
19
+
20
+ ## Models
21
+
22
+ `muse-spark-1.3`, `muse-spark-1.3-contributor`, `muse-spark-1.2`, `muse-spark-1.2-contributor`, `muse-spark-1.1`
23
+
24
+ ## Verify
25
+
26
+ ```bash
27
+ pi -p --provider meta --model muse-spark-1.3 "Reply exactly: META_OK"
28
+ ```
@@ -0,0 +1,851 @@
1
+ import type {
2
+ Api,
3
+ Model,
4
+ ModelsStoreEntry,
5
+ OAuthCredentials,
6
+ OAuthLoginCallbacks,
7
+ RefreshModelsContext,
8
+ } from "@earendil-works/pi-ai";
9
+ import type {
10
+ ExtensionAPI,
11
+ ProviderConfig,
12
+ } from "@earendil-works/pi-coding-agent";
13
+
14
+ export const META_PROVIDER_ID = "meta";
15
+ export const META_API_BASE_URL = "https://api.meta.ai/v1";
16
+ export const META_MODEL_CATALOG_URL = "https://api.meta.ai/v1/models";
17
+ export const META_AUTH_BASE_URL = "https://auth.meta.com";
18
+ export const META_CLIENT_ID = "1031625952748946";
19
+ const META_ENV_VAR = "META_API_KEY";
20
+
21
+ const DEVICE_AUTHORIZATION_URL = `${META_AUTH_BASE_URL}/oidc/device/authorization/`;
22
+ const DEVICE_TOKEN_URL = `${META_AUTH_BASE_URL}/oidc/device/token/`;
23
+ const API_KEY_MINT_URL = "https://api.meta.ai/muse-code/key";
24
+ const DEVICE_CODE_GRANT = "urn:ietf:params:oauth:grant-type:device_code";
25
+ const API_KEY_REFRESH_INTERVAL_MS = 24 * 60 * 60 * 1000;
26
+
27
+ /**
28
+ * Marks credentials created by pasting a Model API key instead of the device
29
+ * flow. The marker lives in `refresh` (OAuthCredentials requires one), so
30
+ * refreshMetaToken can tell a static key from an identity token and must not
31
+ * send it to the mint endpoint.
32
+ */
33
+ export const STATIC_API_KEY_PREFIX = "static-api-key:";
34
+
35
+ export type MetaProviderModel = NonNullable<ProviderConfig["models"]>[number];
36
+ type Fetch = typeof fetch;
37
+ type Sleep = (milliseconds: number) => Promise<void>;
38
+
39
+ interface DeviceAuthorization {
40
+ device_code: string;
41
+ user_code: string;
42
+ verification_uri: string;
43
+ verification_uri_complete?: string;
44
+ expires_in?: number;
45
+ interval?: number;
46
+ }
47
+
48
+ interface DeviceTokenGrant {
49
+ access_token: string;
50
+ }
51
+
52
+ interface OAuthError {
53
+ error?: string;
54
+ error_description?: string;
55
+ }
56
+
57
+ interface MintResponse {
58
+ api_key?: string;
59
+ base_url?: string;
60
+ require_payment?: boolean;
61
+ action_url?: string;
62
+ }
63
+
64
+ interface CatalogResponse {
65
+ data?: MetaCatalogModel[];
66
+ }
67
+
68
+ interface MetaCatalogModel {
69
+ id?: string;
70
+ metadata?: {
71
+ "muse-code"?: {
72
+ name?: string;
73
+ is_hidden?: boolean;
74
+ reasoning?: boolean;
75
+ modalities?: { input?: string[] };
76
+ limit?: { context?: number; output?: number };
77
+ variants?: Record<string, { reasoningEffort?: string }>;
78
+ cost?: {
79
+ input?: string | number;
80
+ output?: string | number;
81
+ cached?: string | number;
82
+ };
83
+ };
84
+ };
85
+ }
86
+
87
+ const FALLBACK_MODELS: MetaProviderModel[] = [
88
+ {
89
+ id: "muse-spark-1.3",
90
+ name: "Muse Spark 1.3",
91
+ reasoning: true,
92
+ thinkingLevelMap: {
93
+ off: null,
94
+ minimal: "minimal",
95
+ low: "low",
96
+ medium: "medium",
97
+ high: "high",
98
+ xhigh: "xhigh",
99
+ max: "max",
100
+ },
101
+ input: ["text", "image"],
102
+ cost: { input: 1.25, output: 4.25, cacheRead: 0.15, cacheWrite: 0 },
103
+ contextWindow: 1_048_576,
104
+ maxTokens: 256_000,
105
+ compat: { supportsReasoningEffort: true, supportsToolSearch: true },
106
+ },
107
+ {
108
+ id: "muse-spark-1.3-contributor",
109
+ name: "Muse Spark 1.3 Contributor",
110
+ reasoning: true,
111
+ thinkingLevelMap: {
112
+ off: null,
113
+ minimal: "minimal",
114
+ low: "low",
115
+ medium: "medium",
116
+ high: "high",
117
+ xhigh: "xhigh",
118
+ max: null,
119
+ },
120
+ input: ["text", "image"],
121
+ cost: { input: 0.1, output: 0.2, cacheRead: 0.002, cacheWrite: 0 },
122
+ contextWindow: 1_048_576,
123
+ maxTokens: 256_000,
124
+ compat: { supportsReasoningEffort: true, supportsToolSearch: true },
125
+ },
126
+ {
127
+ id: "muse-spark-1.2",
128
+ name: "Muse Spark 1.2",
129
+ reasoning: true,
130
+ thinkingLevelMap: {
131
+ off: null,
132
+ minimal: "minimal",
133
+ low: "low",
134
+ medium: "medium",
135
+ high: "high",
136
+ xhigh: "xhigh",
137
+ max: null,
138
+ },
139
+ input: ["text", "image"],
140
+ cost: { input: 1.25, output: 4.25, cacheRead: 0.15, cacheWrite: 0 },
141
+ contextWindow: 1_048_576,
142
+ maxTokens: 256_000,
143
+ compat: { supportsReasoningEffort: true, supportsToolSearch: true },
144
+ },
145
+ {
146
+ id: "muse-spark-1.2-contributor",
147
+ name: "Muse Spark 1.2 Contributor",
148
+ reasoning: true,
149
+ thinkingLevelMap: {
150
+ off: null,
151
+ minimal: "minimal",
152
+ low: "low",
153
+ medium: "medium",
154
+ high: "high",
155
+ xhigh: "xhigh",
156
+ max: null,
157
+ },
158
+ input: ["text", "image"],
159
+ cost: { input: 0.1, output: 0.2, cacheRead: 0.002, cacheWrite: 0 },
160
+ contextWindow: 1_048_576,
161
+ maxTokens: 256_000,
162
+ compat: { supportsReasoningEffort: true, supportsToolSearch: true },
163
+ },
164
+ {
165
+ id: "muse-spark-1.1",
166
+ name: "Muse Spark 1.1",
167
+ reasoning: true,
168
+ thinkingLevelMap: {
169
+ off: null,
170
+ minimal: "minimal",
171
+ low: "low",
172
+ medium: "medium",
173
+ high: "high",
174
+ xhigh: "xhigh",
175
+ max: null,
176
+ },
177
+ input: ["text", "image"],
178
+ cost: { input: 1.25, output: 4.25, cacheRead: 0.15, cacheWrite: 0 },
179
+ contextWindow: 1_048_576,
180
+ maxTokens: 256_000,
181
+ compat: { supportsReasoningEffort: true, supportsToolSearch: true },
182
+ },
183
+ ];
184
+
185
+ function delay(milliseconds: number): Promise<void> {
186
+ return new Promise((resolve) => setTimeout(resolve, milliseconds));
187
+ }
188
+
189
+ async function responseBody(
190
+ response: Response,
191
+ ): Promise<Record<string, unknown>> {
192
+ const text = await response.text();
193
+ if (!text) return {};
194
+ try {
195
+ const value = JSON.parse(text) as unknown;
196
+ return value && typeof value === "object" && !Array.isArray(value)
197
+ ? (value as Record<string, unknown>)
198
+ : {};
199
+ } catch {
200
+ return {};
201
+ }
202
+ }
203
+
204
+ function errorDetail(body: Record<string, unknown>): string | undefined {
205
+ for (const key of ["error_description", "detail", "message", "error"]) {
206
+ const value = body[key];
207
+ if (typeof value === "string" && value.trim()) return value.trim();
208
+ }
209
+ return undefined;
210
+ }
211
+
212
+ async function postForm<T>(
213
+ url: string,
214
+ fields: Record<string, string>,
215
+ fetchImpl: Fetch,
216
+ ): Promise<{ response: Response; body: T & Record<string, unknown> }> {
217
+ const response = await fetchImpl(url, {
218
+ method: "POST",
219
+ headers: {
220
+ Accept: "application/json",
221
+ "Content-Type": "application/x-www-form-urlencoded",
222
+ },
223
+ body: new URLSearchParams(fields),
224
+ redirect: "manual",
225
+ });
226
+ return {
227
+ response,
228
+ body: (await responseBody(response)) as T & Record<string, unknown>,
229
+ };
230
+ }
231
+
232
+ function isAbortSignal(value: unknown): value is AbortSignal {
233
+ return (
234
+ typeof value === "object" &&
235
+ value !== null &&
236
+ "aborted" in value &&
237
+ typeof (value as AbortSignal).aborted === "boolean"
238
+ );
239
+ }
240
+
241
+ export async function mintMetaApiKey(
242
+ identityToken: string,
243
+ fetchImpl: Fetch = fetch,
244
+ signal?: AbortSignal,
245
+ ): Promise<string> {
246
+ const response = await fetchImpl(API_KEY_MINT_URL, {
247
+ method: "POST",
248
+ headers: {
249
+ Accept: "application/json",
250
+ Authorization: `Bearer ${identityToken}`,
251
+ "Content-Type": "application/json",
252
+ "x-api-version": "1.0.0",
253
+ },
254
+ body: "{}",
255
+ signal,
256
+ });
257
+ const body = (await responseBody(response)) as MintResponse &
258
+ Record<string, unknown>;
259
+ if (!response.ok) {
260
+ const detail = errorDetail(body);
261
+ if (response.status === 401 || response.status === 403) {
262
+ throw new Error(
263
+ `Meta session expired (HTTP ${response.status}); run /login meta again${detail ? `: ${detail}` : ""}`,
264
+ );
265
+ }
266
+ throw new Error(
267
+ `Meta API-key mint failed (HTTP ${response.status})${detail ? `: ${detail}` : ""}`,
268
+ );
269
+ }
270
+ if (typeof body.api_key !== "string" || !body.api_key) {
271
+ const setup =
272
+ typeof body.action_url === "string" && body.action_url
273
+ ? ` Complete setup at ${body.action_url}.`
274
+ : "";
275
+ throw new Error(`Meta did not issue an API key.${setup}`);
276
+ }
277
+ return body.api_key;
278
+ }
279
+
280
+ /**
281
+ * API-key login: prompt for a Meta Model API key, validate it against the
282
+ * model catalog, and store it as a static credential. No daily re-minting —
283
+ * refreshMetaToken passes the key through unchanged.
284
+ */
285
+ export async function loginMetaWithApiKey(
286
+ callbacks: OAuthLoginCallbacks,
287
+ fetchImpl: Fetch = fetch,
288
+ ): Promise<OAuthCredentials> {
289
+ const key = (
290
+ await callbacks.onPrompt({ message: "Meta Model API key:" })
291
+ ).trim();
292
+ if (!key) throw new Error("Meta login requires an API key");
293
+ callbacks.onProgress?.("Validating Meta Model API key…");
294
+ const response = await fetchImpl(META_MODEL_CATALOG_URL, {
295
+ headers: {
296
+ Accept: "application/json",
297
+ Authorization: `Bearer ${key}`,
298
+ "x-api-version": "1.0.0",
299
+ },
300
+ });
301
+ if (!response.ok) {
302
+ const detail = errorDetail(await responseBody(response));
303
+ if (response.status === 401 || response.status === 403) {
304
+ throw new Error(
305
+ `Meta rejected the API key (HTTP ${response.status})${detail ? `: ${detail}` : ""}`,
306
+ );
307
+ }
308
+ throw new Error(
309
+ `Meta API key validation failed (HTTP ${response.status})${detail ? `: ${detail}` : ""}`,
310
+ );
311
+ }
312
+ return {
313
+ refresh: `${STATIC_API_KEY_PREFIX}${key}`,
314
+ access: key,
315
+ expires: Date.now() + API_KEY_REFRESH_INTERVAL_MS,
316
+ };
317
+ }
318
+
319
+ export async function loginMeta(
320
+ callbacks: OAuthLoginCallbacks,
321
+ fetchImpl: Fetch = fetch,
322
+ sleep: Sleep = delay,
323
+ ): Promise<OAuthCredentials> {
324
+ // Offer API-key login next to the device flow. Hosts without onSelect and
325
+ // dismissed selectors (undefined) keep the original device-flow behavior.
326
+ const method = await callbacks.onSelect?.({
327
+ message: "Select Meta login method:",
328
+ options: [
329
+ { id: "browser", label: "Browser login (Meta device flow)" },
330
+ { id: "api-key", label: "Paste a Model API key" },
331
+ ],
332
+ });
333
+ if (method === "api-key") {
334
+ return loginMetaWithApiKey(callbacks, fetchImpl);
335
+ }
336
+ callbacks.onProgress?.("Starting Meta device authorization…");
337
+ const authorization = await postForm<DeviceAuthorization>(
338
+ DEVICE_AUTHORIZATION_URL,
339
+ { client_id: META_CLIENT_ID },
340
+ fetchImpl,
341
+ );
342
+ if (!authorization.response.ok) {
343
+ throw new Error(
344
+ `Meta login could not be started (HTTP ${authorization.response.status})${errorDetail(authorization.body) ? `: ${errorDetail(authorization.body)}` : ""}`,
345
+ );
346
+ }
347
+ const device = authorization.body;
348
+ if (!device.device_code || !device.user_code || !device.verification_uri) {
349
+ throw new Error("Meta device authorization returned an incomplete response");
350
+ }
351
+
352
+ let intervalSeconds =
353
+ Number.isFinite(device.interval) && Number(device.interval) > 0
354
+ ? Number(device.interval)
355
+ : 5;
356
+ const expiresInSeconds =
357
+ Number.isFinite(device.expires_in) && Number(device.expires_in) > 0
358
+ ? Number(device.expires_in)
359
+ : 900;
360
+ const deadline = Date.now() + expiresInSeconds * 1000;
361
+ callbacks.onDeviceCode({
362
+ userCode: device.user_code,
363
+ verificationUri: device.verification_uri_complete || device.verification_uri,
364
+ intervalSeconds,
365
+ expiresInSeconds,
366
+ });
367
+ callbacks.onProgress?.("Waiting for Meta login approval…");
368
+
369
+ let identityToken: string | undefined;
370
+ while (Date.now() < deadline) {
371
+ await sleep(intervalSeconds * 1000);
372
+ const grant = await postForm<DeviceTokenGrant & OAuthError>(
373
+ DEVICE_TOKEN_URL,
374
+ {
375
+ grant_type: DEVICE_CODE_GRANT,
376
+ device_code: device.device_code,
377
+ client_id: META_CLIENT_ID,
378
+ },
379
+ fetchImpl,
380
+ );
381
+ if (grant.response.ok && grant.body.access_token) {
382
+ identityToken = grant.body.access_token;
383
+ break;
384
+ }
385
+ switch (grant.body.error) {
386
+ case "authorization_pending":
387
+ continue;
388
+ case "slow_down":
389
+ intervalSeconds += 5;
390
+ continue;
391
+ case "access_denied":
392
+ throw new Error("Meta login was denied");
393
+ case "expired_token":
394
+ throw new Error("Meta login request expired");
395
+ default:
396
+ throw new Error(
397
+ `Meta login failed (HTTP ${grant.response.status})${errorDetail(grant.body) ? `: ${errorDetail(grant.body)}` : ""}`,
398
+ );
399
+ }
400
+ }
401
+ if (!identityToken) throw new Error("Meta login request expired");
402
+
403
+ callbacks.onProgress?.("Enabling Meta Model API access…");
404
+ const apiKey = await mintMetaApiKey(identityToken, fetchImpl);
405
+ return {
406
+ refresh: identityToken,
407
+ access: apiKey,
408
+ expires: Date.now() + API_KEY_REFRESH_INTERVAL_MS,
409
+ };
410
+ }
411
+
412
+ export async function refreshMetaToken(
413
+ credentials: OAuthCredentials,
414
+ fetchOrSignal: Fetch | AbortSignal = fetch,
415
+ ): Promise<OAuthCredentials> {
416
+ if (credentials.refresh?.startsWith(STATIC_API_KEY_PREFIX)) {
417
+ // Static API-key login: nothing to re-mint; keep the key, roll expiry.
418
+ return {
419
+ ...credentials,
420
+ access:
421
+ credentials.refresh.slice(STATIC_API_KEY_PREFIX.length) ||
422
+ credentials.access,
423
+ expires: Date.now() + API_KEY_REFRESH_INTERVAL_MS,
424
+ };
425
+ }
426
+ if (!credentials.refresh)
427
+ throw new Error(
428
+ "Meta login is missing its identity token; run /login meta again",
429
+ );
430
+ // Pi 0.83 calls refreshToken(credentials). Pi 0.84 passes AbortSignal as
431
+ // the second argument. Tests inject a fetch mock in that slot.
432
+ const fetchImpl = typeof fetchOrSignal === "function" ? fetchOrSignal : fetch;
433
+ const signal = isAbortSignal(fetchOrSignal) ? fetchOrSignal : undefined;
434
+ if (signal?.aborted) {
435
+ throw new Error("Meta token refresh was cancelled");
436
+ }
437
+ return {
438
+ ...credentials,
439
+ access: await mintMetaApiKey(credentials.refresh, fetchImpl, signal),
440
+ expires: Date.now() + API_KEY_REFRESH_INTERVAL_MS,
441
+ };
442
+ }
443
+
444
+ function finitePositive(value: unknown, fallback: number): number {
445
+ return typeof value === "number" && Number.isFinite(value) && value > 0
446
+ ? value
447
+ : fallback;
448
+ }
449
+
450
+ function numericCost(value: unknown, fallback: number): number {
451
+ const number =
452
+ typeof value === "number"
453
+ ? value
454
+ : typeof value === "string" && value.trim()
455
+ ? Number(value)
456
+ : Number.NaN;
457
+ return Number.isFinite(number) && number >= 0 ? number : fallback;
458
+ }
459
+
460
+ function displayName(id: string): string {
461
+ return id
462
+ .split("-")
463
+ .map((part) => (part ? part[0].toUpperCase() + part.slice(1) : part))
464
+ .join(" ");
465
+ }
466
+
467
+ function modalitiesToInput(
468
+ modalities: string[] | undefined,
469
+ fallback: MetaProviderModel["input"] | undefined,
470
+ ): MetaProviderModel["input"] {
471
+ if (!modalities) return fallback ?? ["text"];
472
+ const input: MetaProviderModel["input"] = ["text"];
473
+ if (modalities.includes("image")) input.push("image");
474
+ return input;
475
+ }
476
+
477
+ export function toProviderModels(
478
+ catalog: CatalogResponse,
479
+ ): MetaProviderModel[] {
480
+ return (catalog.data ?? []).flatMap((entry) => {
481
+ if (typeof entry.id !== "string" || !entry.id) return [];
482
+ const metadata = entry.metadata?.["muse-code"];
483
+ if (metadata?.is_hidden) return [];
484
+ const fallback = FALLBACK_MODELS.find((model) => model.id === entry.id);
485
+ const catalogName = metadata?.name === entry.id ? undefined : metadata?.name;
486
+ const variants = metadata?.variants ?? {};
487
+ const thinkingLevelMap: NonNullable<MetaProviderModel["thinkingLevelMap"]> = {
488
+ off: null,
489
+ minimal: variants.minimal?.reasoningEffort ?? "minimal",
490
+ low: variants.low?.reasoningEffort ?? "low",
491
+ medium: variants.medium?.reasoningEffort ?? "medium",
492
+ high: variants.high?.reasoningEffort ?? "high",
493
+ xhigh: variants.xhigh?.reasoningEffort ?? "xhigh",
494
+ max: variants.max?.reasoningEffort ?? fallback?.thinkingLevelMap?.max ?? null,
495
+ };
496
+ return [
497
+ {
498
+ id: entry.id,
499
+ name: catalogName || fallback?.name || displayName(entry.id),
500
+ reasoning: metadata?.reasoning ?? fallback?.reasoning ?? true,
501
+ thinkingLevelMap,
502
+ input: modalitiesToInput(metadata?.modalities?.input, fallback?.input),
503
+ cost: {
504
+ input: numericCost(metadata?.cost?.input, fallback?.cost.input ?? 0),
505
+ output: numericCost(metadata?.cost?.output, fallback?.cost.output ?? 0),
506
+ cacheRead: numericCost(
507
+ metadata?.cost?.cached,
508
+ fallback?.cost.cacheRead ?? 0,
509
+ ),
510
+ cacheWrite: 0,
511
+ },
512
+ contextWindow: finitePositive(
513
+ metadata?.limit?.context,
514
+ fallback?.contextWindow ?? 1_048_576,
515
+ ),
516
+ maxTokens: finitePositive(
517
+ metadata?.limit?.output,
518
+ fallback?.maxTokens ?? 256_000,
519
+ ),
520
+ compat: { supportsReasoningEffort: true, supportsToolSearch: true },
521
+ } satisfies MetaProviderModel,
522
+ ];
523
+ });
524
+ }
525
+
526
+ interface CatalogStore {
527
+ read(): Promise<ModelsStoreEntry | undefined>;
528
+ write(entry: ModelsStoreEntry): Promise<void>;
529
+ }
530
+
531
+ interface CompatibleRefreshContext {
532
+ credential?: RefreshModelsContext["credential"];
533
+ allowNetwork: boolean;
534
+ signal?: AbortSignal;
535
+ // Pi 0.83 catalog persistence API.
536
+ store?: CatalogStore;
537
+ // Pi 0.84 generation-checked catalog persistence API.
538
+ stored?: Readonly<ModelsStoreEntry>;
539
+ publish?(publication: { persist?: ModelsStoreEntry | null }): Promise<boolean>;
540
+ }
541
+
542
+ function providerModelsFromStore(
543
+ entry: Readonly<ModelsStoreEntry> | undefined,
544
+ ): MetaProviderModel[] {
545
+ return (entry?.models ?? []).flatMap((model: Model<Api>) => {
546
+ if (model.provider !== META_PROVIDER_ID || model.api !== "openai-responses")
547
+ return [];
548
+ return [
549
+ {
550
+ id: model.id,
551
+ name: model.name,
552
+ api: model.api,
553
+ baseUrl: model.baseUrl,
554
+ reasoning: model.reasoning,
555
+ thinkingLevelMap: model.thinkingLevelMap,
556
+ input: model.input as MetaProviderModel["input"],
557
+ cost: model.cost,
558
+ contextWindow: model.contextWindow,
559
+ maxTokens: model.maxTokens,
560
+ headers: model.headers,
561
+ compat: model.compat as MetaProviderModel["compat"],
562
+ },
563
+ ];
564
+ });
565
+ }
566
+
567
+ function modelsForStore(
568
+ models: MetaProviderModel[],
569
+ ): Model<"openai-responses">[] {
570
+ return models.map((model) => ({
571
+ ...model,
572
+ api: "openai-responses",
573
+ provider: META_PROVIDER_ID,
574
+ baseUrl: model.baseUrl ?? META_API_BASE_URL,
575
+ input: model.input as Model<"openai-responses">["input"],
576
+ compat: model.compat as Model<"openai-responses">["compat"],
577
+ }));
578
+ }
579
+
580
+ async function cachedMetaModels(
581
+ context: CompatibleRefreshContext,
582
+ ): Promise<MetaProviderModel[]> {
583
+ try {
584
+ const stored = context.stored ?? (await context.store?.read());
585
+ return providerModelsFromStore(stored);
586
+ } catch {
587
+ // Catalog persistence is best-effort; bundled fallbacks remain available.
588
+ return [];
589
+ }
590
+ }
591
+
592
+ async function persistMetaModels(
593
+ context: CompatibleRefreshContext,
594
+ entry: ModelsStoreEntry,
595
+ ): Promise<void> {
596
+ if (context.publish) {
597
+ await context.publish({ persist: entry });
598
+ return;
599
+ }
600
+ await context.store?.write(entry);
601
+ }
602
+
603
+ export async function refreshMetaModels(
604
+ context: RefreshModelsContext,
605
+ fetchImpl: Fetch = fetch,
606
+ ): Promise<MetaProviderModel[]> {
607
+ // SAFETY: CompatibleRefreshContext is the union of the Pi 0.83 and 0.84
608
+ // refresh-context fields that this adapter probes defensively at runtime.
609
+ const compatibleContext = context as unknown as CompatibleRefreshContext;
610
+ if (!context.allowNetwork || context.signal?.aborted) {
611
+ const cached = await cachedMetaModels(compatibleContext);
612
+ return cached.length > 0 ? cached : [...FALLBACK_MODELS];
613
+ }
614
+ const apiKey =
615
+ context.credential?.type === "oauth"
616
+ ? context.credential.access
617
+ : context.credential?.type === "api_key"
618
+ ? context.credential.key
619
+ : undefined;
620
+ if (!apiKey) {
621
+ const cached = await cachedMetaModels(compatibleContext);
622
+ return cached.length > 0 ? cached : [...FALLBACK_MODELS];
623
+ }
624
+
625
+ try {
626
+ const response = await fetchImpl(META_MODEL_CATALOG_URL, {
627
+ headers: {
628
+ Accept: "application/json",
629
+ Authorization: `Bearer ${apiKey}`,
630
+ "x-api-version": "1.0.0",
631
+ },
632
+ signal: context.signal,
633
+ });
634
+ const body = (await responseBody(response)) as CatalogResponse &
635
+ Record<string, unknown>;
636
+ if (!response.ok) {
637
+ throw new Error(
638
+ `Meta model catalog failed (HTTP ${response.status})${errorDetail(body) ? `: ${errorDetail(body)}` : ""}`,
639
+ );
640
+ }
641
+ const models = toProviderModels(body);
642
+ if (models.length === 0) {
643
+ const cached = await cachedMetaModels(compatibleContext);
644
+ return cached.length > 0 ? cached : [...FALLBACK_MODELS];
645
+ }
646
+ if (!context.signal?.aborted) {
647
+ try {
648
+ await persistMetaModels(compatibleContext, {
649
+ models: modelsForStore(models),
650
+ checkedAt: Date.now(),
651
+ });
652
+ } catch {
653
+ // Keep the fresh catalog usable even if persistence fails.
654
+ }
655
+ }
656
+ return models;
657
+ } catch (error) {
658
+ if (context.signal?.aborted) throw error;
659
+ const cached = await cachedMetaModels(compatibleContext);
660
+ return cached.length > 0 ? cached : [...FALLBACK_MODELS];
661
+ }
662
+ }
663
+
664
+ export function metaFallbackCost(
665
+ modelId: string,
666
+ ): MetaProviderModel["cost"] | undefined {
667
+ return FALLBACK_MODELS.find((model) => model.id === modelId)?.cost;
668
+ }
669
+
670
+ /** Meta prompt-cache opt-in. Measured 0% hits on /chat/completions vs 93–99% on /responses with 24h. */
671
+ export const META_PROMPT_CACHE_RETENTION = "24h";
672
+
673
+ const ENCRYPTED_REASONING_INCLUDE = "reasoning.encrypted_content";
674
+ const PROBE_RETRY_MS = 5 * 60 * 1000;
675
+
676
+ /**
677
+ * Keys minted through /muse-code/key are not always entitled to encrypted
678
+ * reasoning replay (Meta answers HTTP 400 "reasoning `encrypted_content`
679
+ * was not issued to this caller" when they aren't). Entitlement can change
680
+ * between minted keys, so probe once per key per process instead of
681
+ * hard-coding a decision: requests never 400 and reasoning continuity is
682
+ * kept whenever the key allows it.
683
+ */
684
+ interface EntitlementCache {
685
+ keyHash?: string;
686
+ known?: boolean;
687
+ lastAttemptAt: number;
688
+ }
689
+ const entitlementCache: EntitlementCache = { lastAttemptAt: 0 };
690
+
691
+ function apiKeyHash(key: string): string {
692
+ // Non-cryptographic FNV-1a; only used to key the in-process probe cache.
693
+ let hash = 2166136261;
694
+ for (let i = 0; i < key.length; i++) {
695
+ hash ^= key.charCodeAt(i);
696
+ hash = Math.imul(hash, 16777619);
697
+ }
698
+ return (hash >>> 0).toString(16);
699
+ }
700
+
701
+ /**
702
+ * Probe whether the given API key can request reasoning.encrypted_content.
703
+ * Returns true (200), false (Meta rejects the include), or undefined when
704
+ * the probe was inconclusive (transient error) and must be retried later.
705
+ */
706
+ export async function probeEncryptedReasoningEntitlement(
707
+ apiKey: string,
708
+ fetchImpl: Fetch = fetch,
709
+ ): Promise<boolean | undefined> {
710
+ try {
711
+ const response = await fetchImpl(`${META_API_BASE_URL}/responses`, {
712
+ method: "POST",
713
+ headers: {
714
+ Accept: "application/json",
715
+ Authorization: `Bearer ${apiKey}`,
716
+ "Content-Type": "application/json",
717
+ "x-api-version": "1.0.0",
718
+ },
719
+ body: JSON.stringify({
720
+ model: "muse-spark-1.3",
721
+ input: "Answer with the single letter: a",
722
+ include: [ENCRYPTED_REASONING_INCLUDE],
723
+ max_output_tokens: 16,
724
+ store: false,
725
+ }),
726
+ });
727
+ if (response.status === 200) return true;
728
+ if (response.status === 400) {
729
+ const text = await response.text();
730
+ if (text.includes("encrypted_content")) return false;
731
+ }
732
+ return undefined;
733
+ } catch {
734
+ return undefined;
735
+ }
736
+ }
737
+
738
+ function scheduleEntitlementProbe(apiKey: string): void {
739
+ const hash = apiKeyHash(apiKey);
740
+ if (
741
+ entitlementCache.keyHash === hash &&
742
+ entitlementCache.known !== undefined
743
+ ) {
744
+ return;
745
+ }
746
+ if (Date.now() - entitlementCache.lastAttemptAt < PROBE_RETRY_MS) return;
747
+ entitlementCache.lastAttemptAt = Date.now();
748
+ void probeEncryptedReasoningEntitlement(apiKey).then((known) => {
749
+ if (known !== undefined) {
750
+ entitlementCache.keyHash = hash;
751
+ entitlementCache.known = known;
752
+ }
753
+ });
754
+ }
755
+
756
+ function keepEncryptedReasoningFor(apiKey: string | undefined): boolean {
757
+ if (!apiKey) return false;
758
+ if (entitlementCache.keyHash !== apiKeyHash(apiKey)) return false;
759
+ return entitlementCache.known === true;
760
+ }
761
+
762
+ function asRecord(value: unknown): Record<string, unknown> | undefined {
763
+ return value !== null && typeof value === "object" && !Array.isArray(value)
764
+ ? (value as Record<string, unknown>)
765
+ : undefined;
766
+ }
767
+
768
+ /**
769
+ * Hermes-equivalent Responses hints for api.meta.ai:
770
+ * setdefault `prompt_cache_retention: 24h`, and drop `reasoning.effort: none`
771
+ * because Meta 400s on it.
772
+ */
773
+ export function applyMetaResponsesCacheHints(
774
+ payload: unknown,
775
+ keepEncryptedReasoning = false,
776
+ ): Record<string, unknown> | undefined {
777
+ const body = asRecord(payload);
778
+ if (!body) return undefined;
779
+ if (body.prompt_cache_retention === undefined) {
780
+ body.prompt_cache_retention = META_PROMPT_CACHE_RETENTION;
781
+ }
782
+ // Drop the encrypted-reasoning include unless the key was probed and
783
+ // found entitled: unentitled keys get a fatal HTTP 400 for it (see
784
+ // probeEncryptedReasoningEntitlement). Other include entries survive.
785
+ if (!keepEncryptedReasoning && Array.isArray(body.include)) {
786
+ const include = body.include.filter(
787
+ (item) => item !== "reasoning.encrypted_content",
788
+ );
789
+ if (include.length === 0) delete body.include;
790
+ else body.include = include;
791
+ }
792
+ const reasoning = asRecord(body.reasoning);
793
+ if (
794
+ reasoning &&
795
+ (reasoning.effort === "none" ||
796
+ reasoning.effort === undefined ||
797
+ reasoning.effort === null)
798
+ ) {
799
+ delete body.reasoning;
800
+ }
801
+ return body;
802
+ }
803
+
804
+ export function createMetaProviderConfig(): ProviderConfig {
805
+ return {
806
+ name: "Meta Model API",
807
+ baseUrl: META_API_BASE_URL,
808
+ api: "openai-responses",
809
+ apiKey: "$META_API_KEY",
810
+ models: [...FALLBACK_MODELS],
811
+ refreshModels: refreshMetaModels,
812
+ oauth: {
813
+ name: "Meta Model API (browser login or API key)",
814
+ login: loginMeta,
815
+ refreshToken: refreshMetaToken,
816
+ getApiKey: (credentials: { access: string }) => credentials.access,
817
+ },
818
+ };
819
+ }
820
+
821
+ export default function metaOAuthProvider(pi: ExtensionAPI): void {
822
+ // Allow MODEL_API_KEY as fallback for API-key users — shim to META_API_KEY so $META_API_KEY interpolation works.
823
+ if (
824
+ process.env[META_ENV_VAR] === undefined &&
825
+ process.env["MODEL_API_KEY"] !== undefined
826
+ ) {
827
+ process.env[META_ENV_VAR] = process.env["MODEL_API_KEY"];
828
+ }
829
+ if (
830
+ process.env["MODEL_API_KEY"] === undefined &&
831
+ process.env[META_ENV_VAR] !== undefined
832
+ ) {
833
+ process.env["MODEL_API_KEY"] = process.env[META_ENV_VAR];
834
+ }
835
+ pi.registerProvider(META_PROVIDER_ID, createMetaProviderConfig());
836
+ pi.on("before_provider_request", async (event, ctx) => {
837
+ if (ctx.model?.provider !== META_PROVIDER_ID) return undefined;
838
+ let apiKey: string | undefined;
839
+ try {
840
+ apiKey = (await ctx.modelRegistry?.getProviderAuth(META_PROVIDER_ID))
841
+ ?.auth?.apiKey;
842
+ } catch {
843
+ apiKey = undefined;
844
+ }
845
+ if (apiKey) scheduleEntitlementProbe(apiKey);
846
+ return applyMetaResponsesCacheHints(
847
+ event.payload,
848
+ keepEncryptedReasoningFor(apiKey),
849
+ );
850
+ });
851
+ }
package/package.json ADDED
@@ -0,0 +1,50 @@
1
+ {
2
+ "name": "@geocine/pi-meta-oauth",
3
+ "version": "0.6.1",
4
+ "description": "Meta Model API OAuth provider for pi",
5
+ "keywords": [
6
+ "pi-package",
7
+ "meta",
8
+ "muse-spark",
9
+ "oauth",
10
+ "provider"
11
+ ],
12
+ "license": "MIT",
13
+ "publishConfig": {
14
+ "access": "public"
15
+ },
16
+ "type": "module",
17
+ "files": [
18
+ "LICENSE",
19
+ "README.md",
20
+ "extensions/"
21
+ ],
22
+ "repository": {
23
+ "type": "git",
24
+ "url": "https://github.com/BlockedPath/pi-meta-oauth"
25
+ },
26
+ "homepage": "https://github.com/BlockedPath/pi-meta-oauth#readme",
27
+ "bugs": {
28
+ "url": "https://github.com/BlockedPath/pi-meta-oauth/issues"
29
+ },
30
+ "pi": {
31
+ "extensions": [
32
+ "./extensions/meta.ts"
33
+ ],
34
+ "image": "https://raw.githubusercontent.com/BlockedPath/pi-meta-oauth/main/preview.png"
35
+ },
36
+ "peerDependencies": {
37
+ "@earendil-works/pi-ai": ">=0.83.0 <0.86.0",
38
+ "@earendil-works/pi-coding-agent": ">=0.83.0 <0.86.0"
39
+ },
40
+ "devDependencies": {
41
+ "@earendil-works/pi-ai": "0.85.1",
42
+ "@earendil-works/pi-coding-agent": "0.85.1",
43
+ "bun-types": "latest",
44
+ "typescript": "^5.9.0"
45
+ },
46
+ "scripts": {
47
+ "typecheck": "tsc --noEmit",
48
+ "test": "bun test"
49
+ }
50
+ }