pi-meta-oauth 0.7.0 → 0.7.2

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.
@@ -1,789 +1,36 @@
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
- /**
20
- * User-Agent captured from the Muse CLI inference entrypoint, kept
21
- * byte-for-byte (including the linux-x86_64 platform) because it is a
22
- * captured fingerprint: variants for other platforms are unverified.
23
- *
24
- * Sending it on direct Meta Model API requests enables `reasoning.effort:
25
- * "max"` on muse-spark-1.3-contributor (live-verified 2026-09-25 on
26
- * login-minted credentials: identical payloads 400 without it and 200
27
- * with it; API-key parity reported in oh-my-pi#12199). Observed wire
28
- * behavior, not a permission claim: Meta documents `max` for standard-tier
29
- * 1.3 only, and the gate may change server-side without notice.
30
- *
31
- * Off by default. It impersonates Meta's first-party client, so users opt
32
- * in with {@link MUSE_USER_AGENT_ENV_VAR}; see {@link isMuseUserAgentEnabled}.
33
- */
34
- export const MUSE_USER_AGENT =
35
- "muse-build/1.3.0 (non-interactive; linux-x86_64; build ac7280f2aca67769d1455a8847bb502b617d50f6)";
36
- /** Set to `1`/`true`/`yes` to send {@link MUSE_USER_AGENT} and expose Contributor `max`. */
37
- export const MUSE_USER_AGENT_ENV_VAR = "META_MUSE_USER_AGENT";
38
- /** Models whose `max` effort Meta accepts only with the Muse fingerprint. */
39
- const MUSE_USER_AGENT_MODEL_IDS: ReadonlySet<string> = new Set([
40
- "muse-spark-1.3-contributor",
41
- ]);
42
- const META_ENV_VAR = "META_API_KEY";
43
-
44
- const DEVICE_AUTHORIZATION_URL = `${META_AUTH_BASE_URL}/oidc/device/authorization/`;
45
- const DEVICE_TOKEN_URL = `${META_AUTH_BASE_URL}/oidc/device/token/`;
46
- const API_KEY_MINT_URL = "https://api.meta.ai/muse-code/key";
47
- const DEVICE_CODE_GRANT = "urn:ietf:params:oauth:grant-type:device_code";
48
- const API_KEY_REFRESH_INTERVAL_MS = 24 * 60 * 60 * 1000;
49
-
50
- export type MetaProviderModel = NonNullable<ProviderConfig["models"]>[number];
51
- type Fetch = typeof fetch;
52
- type Sleep = (milliseconds: number) => Promise<void>;
53
-
54
- interface DeviceAuthorization {
55
- device_code: string;
56
- user_code: string;
57
- verification_uri: string;
58
- verification_uri_complete?: string;
59
- expires_in?: number;
60
- interval?: number;
61
- }
62
-
63
- interface DeviceTokenGrant {
64
- access_token: string;
65
- }
66
-
67
- interface OAuthError {
68
- error?: string;
69
- error_description?: string;
70
- }
71
-
72
- interface MintResponse {
73
- api_key?: string;
74
- base_url?: string;
75
- require_payment?: boolean;
76
- action_url?: string;
77
- }
78
-
79
- interface CatalogResponse {
80
- data?: MetaCatalogModel[];
81
- }
82
-
83
- interface MetaCatalogModel {
84
- id?: string;
85
- metadata?: {
86
- "muse-code"?: {
87
- name?: string;
88
- is_hidden?: boolean;
89
- reasoning?: boolean;
90
- modalities?: { input?: string[] };
91
- limit?: { context?: number; output?: number };
92
- variants?: Record<string, { reasoningEffort?: string }>;
93
- cost?: {
94
- input?: string | number;
95
- output?: string | number;
96
- cached?: string | number;
97
- };
98
- };
99
- };
100
- }
101
-
102
- const FALLBACK_MODELS: MetaProviderModel[] = [
103
- {
104
- id: "muse-spark-1.3",
105
- name: "Muse Spark 1.3",
106
- reasoning: true,
107
- thinkingLevelMap: {
108
- off: null,
109
- minimal: "minimal",
110
- low: "low",
111
- medium: "medium",
112
- high: "high",
113
- xhigh: "xhigh",
114
- max: "max",
115
- },
116
- input: ["text", "image"],
117
- cost: { input: 1.25, output: 4.25, cacheRead: 0.15, cacheWrite: 0 },
118
- contextWindow: 1_048_576,
119
- maxTokens: 256_000,
120
- compat: { supportsReasoningEffort: true, supportsToolSearch: true },
121
- },
122
- {
123
- id: "muse-spark-1.3-contributor",
124
- name: "Muse Spark 1.3 Contributor",
125
- reasoning: true,
126
- thinkingLevelMap: {
127
- off: null,
128
- minimal: "minimal",
129
- low: "low",
130
- medium: "medium",
131
- high: "high",
132
- xhigh: "xhigh",
133
- // Accepted only with the Muse User-Agent fingerprint (live-verified
134
- // 2026-09-25). gateMuseMaxEffort() maps this to "max" when
135
- // META_MUSE_USER_AGENT opts in; otherwise it stays unexposed.
136
- max: null,
137
- },
138
- input: ["text", "image"],
139
- cost: { input: 0.1, output: 0.2, cacheRead: 0.002, cacheWrite: 0 },
140
- contextWindow: 1_048_576,
141
- maxTokens: 256_000,
142
- compat: { supportsReasoningEffort: true, supportsToolSearch: true },
143
- },
144
- {
145
- id: "muse-spark-1.2",
146
- name: "Muse Spark 1.2",
147
- reasoning: true,
148
- thinkingLevelMap: {
149
- off: null,
150
- minimal: "minimal",
151
- low: "low",
152
- medium: "medium",
153
- high: "high",
154
- xhigh: "xhigh",
155
- max: null,
156
- },
157
- input: ["text", "image"],
158
- cost: { input: 1.25, output: 4.25, cacheRead: 0.15, cacheWrite: 0 },
159
- contextWindow: 1_048_576,
160
- maxTokens: 256_000,
161
- compat: { supportsReasoningEffort: true, supportsToolSearch: true },
162
- },
163
- {
164
- id: "muse-spark-1.2-contributor",
165
- name: "Muse Spark 1.2 Contributor",
166
- reasoning: true,
167
- thinkingLevelMap: {
168
- off: null,
169
- minimal: "minimal",
170
- low: "low",
171
- medium: "medium",
172
- high: "high",
173
- xhigh: "xhigh",
174
- max: null,
175
- },
176
- input: ["text", "image"],
177
- cost: { input: 0.1, output: 0.2, cacheRead: 0.002, cacheWrite: 0 },
178
- contextWindow: 1_048_576,
179
- maxTokens: 256_000,
180
- compat: { supportsReasoningEffort: true, supportsToolSearch: true },
181
- },
182
- {
183
- id: "muse-spark-1.1",
184
- name: "Muse Spark 1.1",
185
- reasoning: true,
186
- thinkingLevelMap: {
187
- off: null,
188
- minimal: "minimal",
189
- low: "low",
190
- medium: "medium",
191
- high: "high",
192
- xhigh: "xhigh",
193
- max: null,
194
- },
195
- input: ["text", "image"],
196
- cost: { input: 1.25, output: 4.25, cacheRead: 0.15, cacheWrite: 0 },
197
- contextWindow: 1_048_576,
198
- maxTokens: 256_000,
199
- compat: { supportsReasoningEffort: true, supportsToolSearch: true },
200
- },
201
- ];
202
-
203
- type MetaEnv = Record<string, string | undefined>;
204
-
205
- /** True when the user opted in to the Muse User-Agent fingerprint. */
206
- export function isMuseUserAgentEnabled(env: MetaEnv = process.env): boolean {
207
- const value = env[MUSE_USER_AGENT_ENV_VAR]?.trim().toLowerCase();
208
- return value === "1" || value === "true" || value === "yes";
209
- }
210
-
211
- /**
212
- * Expose `max` on fingerprint-gated models only when the fingerprint is
213
- * enabled, so users who have not opted in are never offered a level that
214
- * 400s. Any other explicit `max` mapping (e.g. a future server-advertised
215
- * effort name) passes through unchanged.
216
- */
217
- function gateMuseMaxEffort(
218
- model: MetaProviderModel,
219
- env: MetaEnv = process.env,
220
- ): MetaProviderModel {
221
- const map = model.thinkingLevelMap;
222
- if (!map || !MUSE_USER_AGENT_MODEL_IDS.has(model.id)) return model;
223
- if (map.max != null && map.max !== "max") return model;
224
- return {
225
- ...model,
226
- thinkingLevelMap: { ...map, max: isMuseUserAgentEnabled(env) ? "max" : null },
227
- };
228
- }
229
-
230
- function fallbackModels(): MetaProviderModel[] {
231
- return FALLBACK_MODELS.map((model) => gateMuseMaxEffort(model));
232
- }
233
-
234
- function delay(milliseconds: number): Promise<void> {
235
- return new Promise((resolve) => setTimeout(resolve, milliseconds));
236
- }
237
-
238
- async function responseBody(
239
- response: Response,
240
- ): Promise<Record<string, unknown>> {
241
- const text = await response.text();
242
- if (!text) return {};
243
- try {
244
- const value = JSON.parse(text) as unknown;
245
- return value && typeof value === "object" && !Array.isArray(value)
246
- ? (value as Record<string, unknown>)
247
- : {};
248
- } catch {
249
- return {};
250
- }
251
- }
252
-
253
- function errorDetail(body: Record<string, unknown>): string | undefined {
254
- for (const key of ["error_description", "detail", "message", "error"]) {
255
- const value = body[key];
256
- if (typeof value === "string" && value.trim()) return value.trim();
257
- }
258
- return undefined;
259
- }
260
-
261
- async function postForm<T>(
262
- url: string,
263
- fields: Record<string, string>,
264
- fetchImpl: Fetch,
265
- ): Promise<{ response: Response; body: T & Record<string, unknown> }> {
266
- const response = await fetchImpl(url, {
267
- method: "POST",
268
- headers: {
269
- Accept: "application/json",
270
- "Content-Type": "application/x-www-form-urlencoded",
271
- },
272
- body: new URLSearchParams(fields),
273
- redirect: "manual",
274
- });
275
- return {
276
- response,
277
- body: (await responseBody(response)) as T & Record<string, unknown>,
278
- };
279
- }
280
-
281
- function isAbortSignal(value: unknown): value is AbortSignal {
282
- return (
283
- typeof value === "object" &&
284
- value !== null &&
285
- "aborted" in value &&
286
- typeof (value as AbortSignal).aborted === "boolean"
287
- );
288
- }
289
-
290
- export async function mintMetaApiKey(
291
- identityToken: string,
292
- fetchImpl: Fetch = fetch,
293
- signal?: AbortSignal,
294
- ): Promise<string> {
295
- const response = await fetchImpl(API_KEY_MINT_URL, {
296
- method: "POST",
297
- headers: {
298
- Accept: "application/json",
299
- Authorization: `Bearer ${identityToken}`,
300
- "Content-Type": "application/json",
301
- "x-api-version": "1.0.0",
302
- },
303
- body: "{}",
304
- signal,
305
- });
306
- const body = (await responseBody(response)) as MintResponse &
307
- Record<string, unknown>;
308
- if (!response.ok) {
309
- const detail = errorDetail(body);
310
- if (response.status === 401 || response.status === 403) {
311
- throw new Error(
312
- `Meta session expired (HTTP ${response.status}); run /login meta again${detail ? `: ${detail}` : ""}`,
313
- );
314
- }
315
- throw new Error(
316
- `Meta API-key mint failed (HTTP ${response.status})${detail ? `: ${detail}` : ""}`,
317
- );
318
- }
319
- if (typeof body.api_key !== "string" || !body.api_key) {
320
- const setup =
321
- typeof body.action_url === "string" && body.action_url
322
- ? ` Complete setup at ${body.action_url}.`
323
- : "";
324
- throw new Error(`Meta did not issue an API key.${setup}`);
325
- }
326
- return body.api_key;
327
- }
328
-
329
- export async function loginMeta(
330
- callbacks: OAuthLoginCallbacks,
331
- fetchImpl: Fetch = fetch,
332
- sleep: Sleep = delay,
333
- ): Promise<OAuthCredentials> {
334
- callbacks.onProgress?.("Starting Meta device authorization…");
335
- const authorization = await postForm<DeviceAuthorization>(
336
- DEVICE_AUTHORIZATION_URL,
337
- { client_id: META_CLIENT_ID },
338
- fetchImpl,
339
- );
340
- if (!authorization.response.ok) {
341
- throw new Error(
342
- `Meta login could not be started (HTTP ${authorization.response.status})${errorDetail(authorization.body) ? `: ${errorDetail(authorization.body)}` : ""}`,
343
- );
344
- }
345
- const device = authorization.body;
346
- if (!device.device_code || !device.user_code || !device.verification_uri) {
347
- throw new Error("Meta device authorization returned an incomplete response");
348
- }
349
-
350
- let intervalSeconds =
351
- Number.isFinite(device.interval) && Number(device.interval) > 0
352
- ? Number(device.interval)
353
- : 5;
354
- const expiresInSeconds =
355
- Number.isFinite(device.expires_in) && Number(device.expires_in) > 0
356
- ? Number(device.expires_in)
357
- : 900;
358
- const deadline = Date.now() + expiresInSeconds * 1000;
359
- callbacks.onDeviceCode({
360
- userCode: device.user_code,
361
- verificationUri: device.verification_uri_complete || device.verification_uri,
362
- intervalSeconds,
363
- expiresInSeconds,
364
- });
365
- callbacks.onProgress?.("Waiting for Meta login approval…");
366
-
367
- let identityToken: string | undefined;
368
- while (Date.now() < deadline) {
369
- await sleep(intervalSeconds * 1000);
370
- const grant = await postForm<DeviceTokenGrant & OAuthError>(
371
- DEVICE_TOKEN_URL,
372
- {
373
- grant_type: DEVICE_CODE_GRANT,
374
- device_code: device.device_code,
375
- client_id: META_CLIENT_ID,
376
- },
377
- fetchImpl,
378
- );
379
- if (grant.response.ok && grant.body.access_token) {
380
- identityToken = grant.body.access_token;
381
- break;
382
- }
383
- switch (grant.body.error) {
384
- case "authorization_pending":
385
- continue;
386
- case "slow_down":
387
- intervalSeconds += 5;
388
- continue;
389
- case "access_denied":
390
- throw new Error("Meta login was denied");
391
- case "expired_token":
392
- throw new Error("Meta login request expired");
393
- default:
394
- throw new Error(
395
- `Meta login failed (HTTP ${grant.response.status})${errorDetail(grant.body) ? `: ${errorDetail(grant.body)}` : ""}`,
396
- );
397
- }
398
- }
399
- if (!identityToken) throw new Error("Meta login request expired");
400
-
401
- callbacks.onProgress?.("Enabling Meta Model API access…");
402
- const apiKey = await mintMetaApiKey(identityToken, fetchImpl);
403
- return {
404
- refresh: identityToken,
405
- access: apiKey,
406
- expires: Date.now() + API_KEY_REFRESH_INTERVAL_MS,
407
- };
408
- }
409
-
410
- export async function refreshMetaToken(
411
- credentials: OAuthCredentials,
412
- fetchOrSignal: Fetch | AbortSignal = fetch,
413
- ): Promise<OAuthCredentials> {
414
- if (!credentials.refresh)
415
- throw new Error(
416
- "Meta login is missing its identity token; run /login meta again",
417
- );
418
- // Pi 0.83 calls refreshToken(credentials). Pi 0.84 passes AbortSignal as
419
- // the second argument. Tests inject a fetch mock in that slot.
420
- const fetchImpl = typeof fetchOrSignal === "function" ? fetchOrSignal : fetch;
421
- const signal = isAbortSignal(fetchOrSignal) ? fetchOrSignal : undefined;
422
- if (signal?.aborted) {
423
- throw new Error("Meta token refresh was cancelled");
424
- }
425
- return {
426
- ...credentials,
427
- access: await mintMetaApiKey(credentials.refresh, fetchImpl, signal),
428
- expires: Date.now() + API_KEY_REFRESH_INTERVAL_MS,
429
- };
430
- }
431
-
432
- function finitePositive(value: unknown, fallback: number): number {
433
- return typeof value === "number" && Number.isFinite(value) && value > 0
434
- ? value
435
- : fallback;
436
- }
437
-
438
- function numericCost(value: unknown, fallback: number): number {
439
- const number =
440
- typeof value === "number"
441
- ? value
442
- : typeof value === "string" && value.trim()
443
- ? Number(value)
444
- : Number.NaN;
445
- return Number.isFinite(number) && number >= 0 ? number : fallback;
446
- }
447
-
448
- function displayName(id: string): string {
449
- return id
450
- .split("-")
451
- .map((part) => (part ? part[0].toUpperCase() + part.slice(1) : part))
452
- .join(" ");
453
- }
454
-
455
- function modalitiesToInput(
456
- modalities: string[] | undefined,
457
- fallback: MetaProviderModel["input"] | undefined,
458
- ): MetaProviderModel["input"] {
459
- if (!modalities) return fallback ?? ["text"];
460
- const input: MetaProviderModel["input"] = ["text"];
461
- if (modalities.includes("image")) input.push("image");
462
- return input;
463
- }
464
-
465
- export function toProviderModels(
466
- catalog: CatalogResponse,
467
- ): MetaProviderModel[] {
468
- return (catalog.data ?? []).flatMap((entry) => {
469
- if (typeof entry.id !== "string" || !entry.id) return [];
470
- const metadata = entry.metadata?.["muse-code"];
471
- if (metadata?.is_hidden) return [];
472
- // Gated fallbacks: a server-advertised variants.max still wins below.
473
- const fallback = fallbackModels().find((model) => model.id === entry.id);
474
- const catalogName = metadata?.name === entry.id ? undefined : metadata?.name;
475
- const variants = metadata?.variants ?? {};
476
- const thinkingLevelMap: NonNullable<MetaProviderModel["thinkingLevelMap"]> = {
477
- off: null,
478
- minimal: variants.minimal?.reasoningEffort ?? "minimal",
479
- low: variants.low?.reasoningEffort ?? "low",
480
- medium: variants.medium?.reasoningEffort ?? "medium",
481
- high: variants.high?.reasoningEffort ?? "high",
482
- xhigh: variants.xhigh?.reasoningEffort ?? "xhigh",
483
- max: variants.max?.reasoningEffort ?? fallback?.thinkingLevelMap?.max ?? null,
484
- };
485
- return [
486
- {
487
- id: entry.id,
488
- name: catalogName || fallback?.name || displayName(entry.id),
489
- reasoning: metadata?.reasoning ?? fallback?.reasoning ?? true,
490
- thinkingLevelMap,
491
- input: modalitiesToInput(metadata?.modalities?.input, fallback?.input),
492
- cost: {
493
- input: numericCost(metadata?.cost?.input, fallback?.cost.input ?? 0),
494
- output: numericCost(metadata?.cost?.output, fallback?.cost.output ?? 0),
495
- cacheRead: numericCost(
496
- metadata?.cost?.cached,
497
- fallback?.cost.cacheRead ?? 0,
498
- ),
499
- cacheWrite: 0,
500
- },
501
- contextWindow: finitePositive(
502
- metadata?.limit?.context,
503
- fallback?.contextWindow ?? 1_048_576,
504
- ),
505
- maxTokens: finitePositive(
506
- metadata?.limit?.output,
507
- fallback?.maxTokens ?? 256_000,
508
- ),
509
- compat: { supportsReasoningEffort: true, supportsToolSearch: true },
510
- } satisfies MetaProviderModel,
511
- ];
512
- });
513
- }
514
-
515
- interface CatalogStore {
516
- read(): Promise<ModelsStoreEntry | undefined>;
517
- write(entry: ModelsStoreEntry): Promise<void>;
518
- }
519
-
520
- interface CompatibleRefreshContext {
521
- credential?: RefreshModelsContext["credential"];
522
- allowNetwork: boolean;
523
- signal?: AbortSignal;
524
- // Pi 0.83 catalog persistence API.
525
- store?: CatalogStore;
526
- // Pi 0.84 generation-checked catalog persistence API.
527
- stored?: Readonly<ModelsStoreEntry>;
528
- publish?(publication: { persist?: ModelsStoreEntry | null }): Promise<boolean>;
529
- }
530
-
531
- function providerModelsFromStore(
532
- entry: Readonly<ModelsStoreEntry> | undefined,
533
- ): MetaProviderModel[] {
534
- return (entry?.models ?? []).flatMap((model: Model<Api>) => {
535
- if (model.provider !== META_PROVIDER_ID || model.api !== "openai-responses")
536
- return [];
537
- // Re-gate on restore: a catalog cached while the fingerprint was enabled
538
- // must not keep offering Contributor `max` after the user opts out.
539
- return [
540
- gateMuseMaxEffort({
541
- id: model.id,
542
- name: model.name,
543
- api: model.api,
544
- baseUrl: model.baseUrl,
545
- reasoning: model.reasoning,
546
- thinkingLevelMap: model.thinkingLevelMap,
547
- input: model.input as MetaProviderModel["input"],
548
- cost: model.cost,
549
- contextWindow: model.contextWindow,
550
- maxTokens: model.maxTokens,
551
- headers: model.headers,
552
- compat: model.compat as MetaProviderModel["compat"],
553
- }),
554
- ];
555
- });
556
- }
557
-
558
- function modelsForStore(
559
- models: MetaProviderModel[],
560
- ): Model<"openai-responses">[] {
561
- return models.map((model) => ({
562
- ...model,
563
- api: "openai-responses",
564
- provider: META_PROVIDER_ID,
565
- baseUrl: model.baseUrl ?? META_API_BASE_URL,
566
- input: model.input as Model<"openai-responses">["input"],
567
- compat: model.compat as Model<"openai-responses">["compat"],
568
- }));
569
- }
570
-
571
- async function cachedMetaModels(
572
- context: CompatibleRefreshContext,
573
- ): Promise<MetaProviderModel[]> {
574
- try {
575
- const stored = context.stored ?? (await context.store?.read());
576
- return providerModelsFromStore(stored);
577
- } catch {
578
- // Catalog persistence is best-effort; bundled fallbacks remain available.
579
- return [];
580
- }
581
- }
582
-
583
- async function persistMetaModels(
584
- context: CompatibleRefreshContext,
585
- entry: ModelsStoreEntry,
586
- ): Promise<void> {
587
- if (context.publish) {
588
- await context.publish({ persist: entry });
589
- return;
590
- }
591
- await context.store?.write(entry);
592
- }
593
-
594
- export async function refreshMetaModels(
595
- context: RefreshModelsContext,
596
- fetchImpl: Fetch = fetch,
597
- ): Promise<MetaProviderModel[]> {
598
- // SAFETY: CompatibleRefreshContext is the union of the Pi 0.83 and 0.84
599
- // refresh-context fields that this adapter probes defensively at runtime.
600
- const compatibleContext = context as unknown as CompatibleRefreshContext;
601
- if (!context.allowNetwork || context.signal?.aborted) {
602
- const cached = await cachedMetaModels(compatibleContext);
603
- return cached.length > 0 ? cached : fallbackModels();
604
- }
605
- const apiKey =
606
- context.credential?.type === "oauth"
607
- ? context.credential.access
608
- : context.credential?.type === "api_key"
609
- ? context.credential.key
610
- : undefined;
611
- if (!apiKey) {
612
- const cached = await cachedMetaModels(compatibleContext);
613
- return cached.length > 0 ? cached : fallbackModels();
614
- }
615
-
616
- try {
617
- const response = await fetchImpl(META_MODEL_CATALOG_URL, {
618
- headers: {
619
- Accept: "application/json",
620
- Authorization: `Bearer ${apiKey}`,
621
- "x-api-version": "1.0.0",
622
- },
623
- signal: context.signal,
624
- });
625
- const body = (await responseBody(response)) as CatalogResponse &
626
- Record<string, unknown>;
627
- if (!response.ok) {
628
- throw new Error(
629
- `Meta model catalog failed (HTTP ${response.status})${errorDetail(body) ? `: ${errorDetail(body)}` : ""}`,
630
- );
631
- }
632
- const models = toProviderModels(body);
633
- if (models.length === 0) {
634
- const cached = await cachedMetaModels(compatibleContext);
635
- return cached.length > 0 ? cached : fallbackModels();
636
- }
637
- if (!context.signal?.aborted) {
638
- try {
639
- await persistMetaModels(compatibleContext, {
640
- models: modelsForStore(models),
641
- checkedAt: Date.now(),
642
- });
643
- } catch {
644
- // Keep the fresh catalog usable even if persistence fails.
645
- }
646
- }
647
- return models;
648
- } catch (error) {
649
- if (context.signal?.aborted) throw error;
650
- const cached = await cachedMetaModels(compatibleContext);
651
- return cached.length > 0 ? cached : fallbackModels();
652
- }
653
- }
654
-
655
- export function metaFallbackCost(
656
- modelId: string,
657
- ): MetaProviderModel["cost"] | undefined {
658
- return FALLBACK_MODELS.find((model) => model.id === modelId)?.cost;
659
- }
660
-
661
- /** Meta prompt-cache opt-in. Measured 0% hits on /chat/completions vs 93–99% on /responses with 24h. */
662
- export const META_PROMPT_CACHE_RETENTION = "24h";
663
-
664
- function asRecord(value: unknown): Record<string, unknown> | undefined {
665
- return value !== null && typeof value === "object" && !Array.isArray(value)
666
- ? (value as Record<string, unknown>)
667
- : undefined;
668
- }
669
-
670
- /**
671
- * Strict direct-endpoint check: https scheme, api.meta.ai hostname
672
- * (case-insensitive), default port, and a bare /v1 path. WHATWG URL parsing
673
- * normalizes the equivalent forms (uppercase host, explicit :443) to match;
674
- * proxies, lookalike hosts (api.meta.ai.evil.com), subpaths, and
675
- * non-default ports never match, so they never receive the fingerprint.
676
- */
677
- export function isDirectMetaModelApiUrl(baseUrl: unknown): boolean {
678
- if (typeof baseUrl !== "string" || !baseUrl) return false;
679
- let parsed: URL;
680
- try {
681
- parsed = new URL(baseUrl);
682
- } catch {
683
- return false;
684
- }
685
- if (parsed.protocol !== "https:") return false;
686
- if (parsed.hostname.toLowerCase() !== "api.meta.ai") return false;
687
- if (parsed.port !== "") return false;
688
- return parsed.pathname.replace(/\/+$/, "") === "/v1";
689
- }
690
-
691
- function hasUserAgentHeader(
692
- headers: Record<string, string | null>,
693
- ): boolean {
694
- return Object.keys(headers).some(
695
- (name) => name.toLowerCase() === "user-agent",
696
- );
697
- }
698
-
699
- /**
700
- * Setdefault the Muse fingerprint for direct Meta Model API requests.
701
- * Explicit User-Agent headers (any casing, including null suppression)
702
- * always win; anything but the direct endpoint is left untouched.
703
- * Applies regardless of credential type: API-key and Muse Code login
704
- * (minted) credentials both reach the same direct wire. Returns true when
705
- * the fingerprint was applied.
706
- */
707
- export function applyMetaUserAgentFingerprint(
708
- headers: Record<string, string | null>,
709
- baseUrl: unknown,
710
- ): boolean {
711
- if (!isDirectMetaModelApiUrl(baseUrl)) return false;
712
- if (hasUserAgentHeader(headers)) return false;
713
- headers["User-Agent"] = MUSE_USER_AGENT;
714
- return true;
715
- }
716
-
717
- /**
718
- * Hermes-equivalent Responses hints for api.meta.ai:
719
- * setdefault `prompt_cache_retention: 24h`, and drop `reasoning.effort: none`
720
- * because Meta 400s on it.
721
- */
722
- export function applyMetaResponsesCacheHints(
723
- payload: unknown,
724
- ): Record<string, unknown> | undefined {
725
- const body = asRecord(payload);
726
- if (!body) return undefined;
727
- if (body.prompt_cache_retention === undefined) {
728
- body.prompt_cache_retention = META_PROMPT_CACHE_RETENTION;
729
- }
730
- const reasoning = asRecord(body.reasoning);
731
- if (
732
- reasoning &&
733
- (reasoning.effort === "none" ||
734
- reasoning.effort === undefined ||
735
- reasoning.effort === null)
736
- ) {
737
- delete body.reasoning;
738
- }
739
- return body;
740
- }
741
-
742
- export function createMetaProviderConfig(): ProviderConfig {
743
- return {
744
- name: "Meta Model API",
745
- baseUrl: META_API_BASE_URL,
746
- api: "openai-responses",
747
- apiKey: "$META_API_KEY",
748
- models: fallbackModels(),
749
- refreshModels: refreshMetaModels,
750
- oauth: {
751
- name: "Meta Model API (browser login)",
752
- login: loginMeta,
753
- refreshToken: refreshMetaToken,
754
- getApiKey: (credentials: { access: string }) => credentials.access,
755
- },
756
- };
757
- }
1
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
2
+ import { META_PROVIDER_ID } from "../src/meta/constants.ts";
3
+ import { synchronizeMetaApiKeyAliases } from "../src/meta/environment.ts";
4
+ import { createMetaProviderConfig } from "../src/meta/provider.ts";
5
+
6
+ // Preserve the extension's existing named exports for callers and wire tests.
7
+ export {
8
+ META_API_BASE_URL,
9
+ META_AUTH_BASE_URL,
10
+ META_CLIENT_ID,
11
+ META_MODEL_CATALOG_URL,
12
+ META_PROMPT_CACHE_RETENTION,
13
+ META_PROVIDER_ID,
14
+ MUSE_USER_AGENT,
15
+ MUSE_USER_AGENT_ENV_VAR,
16
+ } from "../src/meta/constants.ts";
17
+ export { refreshMetaModels } from "../src/meta/model-store.ts";
18
+ export { metaFallbackCost, toProviderModels } from "../src/meta/models.ts";
19
+ export { isMuseUserAgentEnabled } from "../src/meta/muse-policy.ts";
20
+ export {
21
+ loginMeta,
22
+ mintMetaApiKey,
23
+ refreshMetaToken,
24
+ } from "../src/meta/oauth.ts";
25
+ export { createMetaProviderConfig } from "../src/meta/provider.ts";
26
+ export {
27
+ applyMetaResponsesCacheHints,
28
+ applyMetaUserAgentFingerprint,
29
+ isDirectMetaModelApiUrl,
30
+ } from "../src/meta/request-policy.ts";
31
+ export type { MetaProviderModel } from "../src/meta/types.ts";
758
32
 
759
33
  export default function metaOAuthProvider(pi: ExtensionAPI): void {
760
- // Allow MODEL_API_KEY as fallback for API-key users — shim to META_API_KEY so $META_API_KEY interpolation works.
761
- if (
762
- process.env[META_ENV_VAR] === undefined &&
763
- process.env["MODEL_API_KEY"] !== undefined
764
- ) {
765
- process.env[META_ENV_VAR] = process.env["MODEL_API_KEY"];
766
- }
767
- if (
768
- process.env["MODEL_API_KEY"] === undefined &&
769
- process.env[META_ENV_VAR] !== undefined
770
- ) {
771
- process.env["MODEL_API_KEY"] = process.env[META_ENV_VAR];
772
- }
34
+ synchronizeMetaApiKeyAliases(process.env);
773
35
  pi.registerProvider(META_PROVIDER_ID, createMetaProviderConfig());
774
- pi.on("before_provider_request", (event, ctx) => {
775
- if (ctx.model?.provider !== META_PROVIDER_ID) return undefined;
776
- return applyMetaResponsesCacheHints(event.payload);
777
- });
778
- pi.on("before_provider_headers", (event, ctx) => {
779
- const model = ctx.model;
780
- if (model?.provider !== META_PROVIDER_ID) return;
781
- // Opt-in only, and only for models that need it: every other Meta
782
- // request keeps Pi's own User-Agent.
783
- if (!MUSE_USER_AGENT_MODEL_IDS.has(model.id)) return;
784
- if (!isMuseUserAgentEnabled()) return;
785
- // The composed model always carries the effective baseUrl; without
786
- // it the endpoint cannot be verified, so no fingerprint is sent.
787
- applyMetaUserAgentFingerprint(event.headers, model.baseUrl);
788
- });
789
36
  }