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.
- package/README.md +64 -7
- package/extensions/meta.ts +32 -785
- package/package.json +9 -3
- package/src/meta/constants.ts +28 -0
- package/src/meta/environment.ts +17 -0
- package/src/meta/http.ts +83 -0
- package/src/meta/model-store.ts +183 -0
- package/src/meta/models.ts +355 -0
- package/src/meta/muse-policy.ts +32 -0
- package/src/meta/oauth.ts +382 -0
- package/src/meta/provider.ts +53 -0
- package/src/meta/request-policy.ts +96 -0
- package/src/meta/types.ts +33 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-meta-oauth",
|
|
3
|
-
"version": "0.7.
|
|
3
|
+
"version": "0.7.2",
|
|
4
4
|
"description": "Meta Model API OAuth provider for pi",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"pi-package",
|
|
@@ -14,7 +14,8 @@
|
|
|
14
14
|
"files": [
|
|
15
15
|
"LICENSE",
|
|
16
16
|
"README.md",
|
|
17
|
-
"extensions/"
|
|
17
|
+
"extensions/",
|
|
18
|
+
"src/"
|
|
18
19
|
],
|
|
19
20
|
"repository": {
|
|
20
21
|
"type": "git",
|
|
@@ -35,13 +36,18 @@
|
|
|
35
36
|
"@earendil-works/pi-coding-agent": ">=0.83.0 <0.88.0"
|
|
36
37
|
},
|
|
37
38
|
"devDependencies": {
|
|
39
|
+
"@biomejs/biome": "2.5.15",
|
|
38
40
|
"@earendil-works/pi-ai": "0.87.1",
|
|
39
41
|
"@earendil-works/pi-coding-agent": "0.87.1",
|
|
40
42
|
"bun-types": "latest",
|
|
41
43
|
"typescript": "^5.9.0"
|
|
42
44
|
},
|
|
43
45
|
"scripts": {
|
|
46
|
+
"check": "biome check . && bun run typecheck && bun run test",
|
|
47
|
+
"lint": "biome check .",
|
|
48
|
+
"format": "biome check --write .",
|
|
44
49
|
"typecheck": "tsc --noEmit",
|
|
45
|
-
"test": "bun test"
|
|
50
|
+
"test": "bun run scripts/test.ts",
|
|
51
|
+
"test:live": "bun run scripts/test.ts --live"
|
|
46
52
|
}
|
|
47
53
|
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
export const META_PROVIDER_ID = "meta";
|
|
2
|
+
export const META_API_BASE_URL = "https://api.meta.ai/v1";
|
|
3
|
+
export const META_MODEL_CATALOG_URL = `${META_API_BASE_URL}/models`;
|
|
4
|
+
export const META_AUTH_BASE_URL = "https://auth.meta.com";
|
|
5
|
+
export const META_CLIENT_ID = "1031625952748946";
|
|
6
|
+
export const META_API_VERSION = "1.0.0";
|
|
7
|
+
export const META_ENV_VAR = "META_API_KEY";
|
|
8
|
+
export const MODEL_API_ENV_VAR = "MODEL_API_KEY";
|
|
9
|
+
|
|
10
|
+
export const DEVICE_AUTHORIZATION_URL = `${META_AUTH_BASE_URL}/oidc/device/authorization/`;
|
|
11
|
+
export const DEVICE_TOKEN_URL = `${META_AUTH_BASE_URL}/oidc/device/token/`;
|
|
12
|
+
export const API_KEY_MINT_URL = "https://api.meta.ai/muse-code/key";
|
|
13
|
+
export const DEVICE_CODE_GRANT = "urn:ietf:params:oauth:grant-type:device_code";
|
|
14
|
+
export const API_KEY_REFRESH_INTERVAL_MS = 24 * 60 * 60 * 1000;
|
|
15
|
+
/** Bound each OAuth or mint request, matching Pi's built-in Meta flow. */
|
|
16
|
+
export const META_REQUEST_TIMEOUT_MS = 30_000;
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Captured Muse CLI fingerprint. Preserve its bytes, including the platform
|
|
20
|
+
* segment: alternative fingerprints have not been verified. This is an
|
|
21
|
+
* opt-in compatibility workaround for Contributor `max`, not a supported API
|
|
22
|
+
* contract; see the README for its account and compatibility implications.
|
|
23
|
+
*/
|
|
24
|
+
export const MUSE_USER_AGENT =
|
|
25
|
+
"muse-build/1.3.0 (non-interactive; linux-x86_64; build ac7280f2aca67769d1455a8847bb502b617d50f6)";
|
|
26
|
+
/** Set to `1`, `true`, or `yes` to enable the Muse fingerprint. */
|
|
27
|
+
export const MUSE_USER_AGENT_ENV_VAR = "META_MUSE_USER_AGENT";
|
|
28
|
+
export const META_PROMPT_CACHE_RETENTION = "24h";
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { META_ENV_VAR, MODEL_API_ENV_VAR } from "./constants.ts";
|
|
2
|
+
import type { MetaEnv } from "./types.ts";
|
|
3
|
+
|
|
4
|
+
/** Pi treats an empty $META_API_KEY as unset, and a blank one is never a usable key. */
|
|
5
|
+
function isMissing(value: string | undefined): boolean {
|
|
6
|
+
return !value?.trim();
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
/** Pi interpolates $META_API_KEY; Muse tooling may use MODEL_API_KEY. */
|
|
10
|
+
export function synchronizeMetaApiKeyAliases(env: MetaEnv): void {
|
|
11
|
+
if (isMissing(env[META_ENV_VAR]) && !isMissing(env[MODEL_API_ENV_VAR])) {
|
|
12
|
+
env[META_ENV_VAR] = env[MODEL_API_ENV_VAR];
|
|
13
|
+
}
|
|
14
|
+
if (isMissing(env[MODEL_API_ENV_VAR]) && !isMissing(env[META_ENV_VAR])) {
|
|
15
|
+
env[MODEL_API_ENV_VAR] = env[META_ENV_VAR];
|
|
16
|
+
}
|
|
17
|
+
}
|
package/src/meta/http.ts
ADDED
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import { META_REQUEST_TIMEOUT_MS } from "./constants.ts";
|
|
2
|
+
import type { Fetch } from "./types.ts";
|
|
3
|
+
|
|
4
|
+
/** A request outlived its own timeout while the caller's signal stayed live. */
|
|
5
|
+
export class RequestTimeoutError extends Error {
|
|
6
|
+
override name = "RequestTimeoutError";
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
/** Keep untrusted JSON as unknown until its fields have been validated. */
|
|
10
|
+
export function asRecord(value: unknown): Record<string, unknown> | undefined {
|
|
11
|
+
return value !== null && typeof value === "object" && !Array.isArray(value)
|
|
12
|
+
? (value as Record<string, unknown>)
|
|
13
|
+
: undefined;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export async function responseBody(
|
|
17
|
+
response: Response,
|
|
18
|
+
): Promise<Record<string, unknown>> {
|
|
19
|
+
const text = await response.text();
|
|
20
|
+
if (!text) return {};
|
|
21
|
+
try {
|
|
22
|
+
return asRecord(JSON.parse(text)) ?? {};
|
|
23
|
+
} catch {
|
|
24
|
+
return {};
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function errorDetail(body: Record<string, unknown>): string | undefined {
|
|
29
|
+
for (const key of ["error_description", "detail", "message", "error"]) {
|
|
30
|
+
const value = body[key];
|
|
31
|
+
if (typeof value === "string" && value.trim()) return value.trim();
|
|
32
|
+
}
|
|
33
|
+
return undefined;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Run one request under the caller's signal and its own timeout, so a stalled
|
|
38
|
+
* connection cannot hang a login or refresh. Only a timeout the caller did not
|
|
39
|
+
* cause becomes a RequestTimeoutError; caller cancellation passes through.
|
|
40
|
+
*/
|
|
41
|
+
export async function withRequestTimeout<T>(
|
|
42
|
+
signal: AbortSignal | undefined,
|
|
43
|
+
timeoutMs: number,
|
|
44
|
+
request: (signal: AbortSignal) => Promise<T>,
|
|
45
|
+
): Promise<T> {
|
|
46
|
+
const timeout = AbortSignal.timeout(timeoutMs);
|
|
47
|
+
try {
|
|
48
|
+
return await request(signal ? AbortSignal.any([signal, timeout]) : timeout);
|
|
49
|
+
} catch (error) {
|
|
50
|
+
if (timeout.aborted && !signal?.aborted) {
|
|
51
|
+
throw new RequestTimeoutError(`Request timed out after ${timeoutMs} ms`, {
|
|
52
|
+
cause: error,
|
|
53
|
+
});
|
|
54
|
+
}
|
|
55
|
+
throw error;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export async function postForm(
|
|
60
|
+
url: string,
|
|
61
|
+
fields: Record<string, string>,
|
|
62
|
+
fetchImpl: Fetch,
|
|
63
|
+
signal?: AbortSignal,
|
|
64
|
+
timeoutMs = META_REQUEST_TIMEOUT_MS,
|
|
65
|
+
): Promise<{ response: Response; body: Record<string, unknown> }> {
|
|
66
|
+
return withRequestTimeout(signal, timeoutMs, async (requestSignal) => {
|
|
67
|
+
const response = await fetchImpl(url, {
|
|
68
|
+
method: "POST",
|
|
69
|
+
headers: {
|
|
70
|
+
Accept: "application/json",
|
|
71
|
+
"Content-Type": "application/x-www-form-urlencoded",
|
|
72
|
+
},
|
|
73
|
+
body: new URLSearchParams(fields),
|
|
74
|
+
redirect: "manual",
|
|
75
|
+
signal: requestSignal,
|
|
76
|
+
});
|
|
77
|
+
return { response, body: await responseBody(response) };
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export function delay(milliseconds: number): Promise<void> {
|
|
82
|
+
return new Promise((resolve) => setTimeout(resolve, milliseconds));
|
|
83
|
+
}
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
Model,
|
|
3
|
+
ModelsStoreEntry,
|
|
4
|
+
RefreshModelsContext,
|
|
5
|
+
} from "@earendil-works/pi-ai";
|
|
6
|
+
import {
|
|
7
|
+
META_API_BASE_URL,
|
|
8
|
+
META_API_VERSION,
|
|
9
|
+
META_MODEL_CATALOG_URL,
|
|
10
|
+
META_PROVIDER_ID,
|
|
11
|
+
} from "./constants.ts";
|
|
12
|
+
import { asRecord, errorDetail, responseBody } from "./http.ts";
|
|
13
|
+
import {
|
|
14
|
+
fallbackModels,
|
|
15
|
+
restoreProviderModels,
|
|
16
|
+
toProviderModels,
|
|
17
|
+
} from "./models.ts";
|
|
18
|
+
import type { Fetch, MetaProviderModel } from "./types.ts";
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Pi >=0.86.1 refreshes its built-in pi.dev `meta` overlay before this extension, through the same
|
|
22
|
+
* models-store key, so persisted entries carry a provenance marker.
|
|
23
|
+
*/
|
|
24
|
+
const STORE_SOURCE = "pi-meta-oauth";
|
|
25
|
+
|
|
26
|
+
type OwnedStoreEntry = ModelsStoreEntry & { source: typeof STORE_SOURCE };
|
|
27
|
+
|
|
28
|
+
interface LegacyCatalogStore {
|
|
29
|
+
read(): Promise<unknown>;
|
|
30
|
+
write(entry: ModelsStoreEntry): Promise<void>;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** The runtime adapter spans Pi 0.83's store and Pi 0.84+'s snapshot/publication API. */
|
|
34
|
+
interface CompatibleRefreshContext {
|
|
35
|
+
credential?: RefreshModelsContext["credential"];
|
|
36
|
+
allowNetwork: boolean;
|
|
37
|
+
signal?: AbortSignal;
|
|
38
|
+
store?: LegacyCatalogStore;
|
|
39
|
+
stored?: Readonly<ModelsStoreEntry>;
|
|
40
|
+
publish?(publication: {
|
|
41
|
+
persist?: ModelsStoreEntry | null;
|
|
42
|
+
}): Promise<boolean>;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Accept marked entries and unmarked ones from older releases; pi.dev overlays set lastModified whenever they persist a catalog. */
|
|
46
|
+
function isOwnedEntry(value: unknown): boolean {
|
|
47
|
+
const entry = asRecord(value);
|
|
48
|
+
if (!entry) return false;
|
|
49
|
+
if (entry.source === STORE_SOURCE) return true;
|
|
50
|
+
return (
|
|
51
|
+
entry.source === undefined &&
|
|
52
|
+
entry.lastModified === undefined &&
|
|
53
|
+
entry.etag === undefined
|
|
54
|
+
);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
async function cachedOrFallbackModels(
|
|
58
|
+
context: CompatibleRefreshContext,
|
|
59
|
+
): Promise<MetaProviderModel[]> {
|
|
60
|
+
try {
|
|
61
|
+
const stored = context.stored ?? (await context.store?.read());
|
|
62
|
+
if (isOwnedEntry(stored)) {
|
|
63
|
+
const models = restoreProviderModels(asRecord(stored)?.models);
|
|
64
|
+
if (models.length > 0) return models;
|
|
65
|
+
}
|
|
66
|
+
} catch {
|
|
67
|
+
// Persistence is best-effort; an unreadable cache must not disable the provider.
|
|
68
|
+
}
|
|
69
|
+
return fallbackModels();
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** Undo a pi.dev overlay write from this refresh so the last good Meta catalog stays persisted. */
|
|
73
|
+
async function republishOwnedEntry(
|
|
74
|
+
context: CompatibleRefreshContext,
|
|
75
|
+
): Promise<void> {
|
|
76
|
+
const stored = context.stored;
|
|
77
|
+
if (context.signal?.aborted || !context.publish || !stored) return;
|
|
78
|
+
if (!isOwnedEntry(stored)) return;
|
|
79
|
+
// Keep the original checkedAt: this refresh did not validate the catalog.
|
|
80
|
+
const entry: OwnedStoreEntry = {
|
|
81
|
+
...stored,
|
|
82
|
+
lastModified: 0,
|
|
83
|
+
source: STORE_SOURCE,
|
|
84
|
+
};
|
|
85
|
+
try {
|
|
86
|
+
await context.publish({ persist: entry });
|
|
87
|
+
} catch {
|
|
88
|
+
// The restored catalog remains usable when its persistence fails.
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
async function restoreAfterFailedRefresh(
|
|
93
|
+
context: CompatibleRefreshContext,
|
|
94
|
+
): Promise<MetaProviderModel[]> {
|
|
95
|
+
await republishOwnedEntry(context);
|
|
96
|
+
return cachedOrFallbackModels(context);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function modelsForStore(
|
|
100
|
+
models: MetaProviderModel[],
|
|
101
|
+
): Model<"openai-responses">[] {
|
|
102
|
+
return models.map((model) => {
|
|
103
|
+
const detached = structuredClone(model);
|
|
104
|
+
return {
|
|
105
|
+
...detached,
|
|
106
|
+
api: "openai-responses",
|
|
107
|
+
provider: META_PROVIDER_ID,
|
|
108
|
+
baseUrl: detached.baseUrl ?? META_API_BASE_URL,
|
|
109
|
+
};
|
|
110
|
+
});
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
async function persistModels(
|
|
114
|
+
context: CompatibleRefreshContext,
|
|
115
|
+
models: MetaProviderModel[],
|
|
116
|
+
): Promise<void> {
|
|
117
|
+
const entry: OwnedStoreEntry = {
|
|
118
|
+
models: modelsForStore(models),
|
|
119
|
+
checkedAt: Date.now(),
|
|
120
|
+
// Pi's overlay reads lastModified 0 as "no pi.dev catalog" and skips pi.dev while this entry is fresh.
|
|
121
|
+
lastModified: 0,
|
|
122
|
+
source: STORE_SOURCE,
|
|
123
|
+
};
|
|
124
|
+
if (context.publish) {
|
|
125
|
+
// A false result means this generation was superseded; never bypass that check with a legacy write.
|
|
126
|
+
await context.publish({ persist: entry });
|
|
127
|
+
} else {
|
|
128
|
+
await context.store?.write(entry);
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function modelApiKey(context: CompatibleRefreshContext): string | undefined {
|
|
133
|
+
const credential = context.credential;
|
|
134
|
+
if (credential?.type === "oauth") return credential.access;
|
|
135
|
+
if (credential?.type === "api_key") return credential.key;
|
|
136
|
+
return undefined;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/** Restore an offline catalog or refresh it using the effective OAuth/API-key credential. */
|
|
140
|
+
export async function refreshMetaModels(
|
|
141
|
+
context: RefreshModelsContext,
|
|
142
|
+
fetchImpl: Fetch = fetch,
|
|
143
|
+
): Promise<MetaProviderModel[]> {
|
|
144
|
+
// Pi 0.83 is outside the installed type declarations, but shares these runtime fields.
|
|
145
|
+
const compatibleContext = context as unknown as CompatibleRefreshContext;
|
|
146
|
+
const apiKey = modelApiKey(compatibleContext);
|
|
147
|
+
if (!context.allowNetwork || context.signal?.aborted || !apiKey) {
|
|
148
|
+
return cachedOrFallbackModels(compatibleContext);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
try {
|
|
152
|
+
const response = await fetchImpl(META_MODEL_CATALOG_URL, {
|
|
153
|
+
headers: {
|
|
154
|
+
Accept: "application/json",
|
|
155
|
+
Authorization: `Bearer ${apiKey}`,
|
|
156
|
+
"x-api-version": META_API_VERSION,
|
|
157
|
+
},
|
|
158
|
+
signal: context.signal,
|
|
159
|
+
});
|
|
160
|
+
const body = await responseBody(response);
|
|
161
|
+
if (!response.ok) {
|
|
162
|
+
const detail = errorDetail(body);
|
|
163
|
+
throw new Error(
|
|
164
|
+
`Meta model catalog failed (HTTP ${response.status})${detail ? `: ${detail}` : ""}`,
|
|
165
|
+
);
|
|
166
|
+
}
|
|
167
|
+
const models = toProviderModels(body);
|
|
168
|
+
if (models.length === 0)
|
|
169
|
+
return restoreAfterFailedRefresh(compatibleContext);
|
|
170
|
+
if (!context.signal?.aborted) {
|
|
171
|
+
try {
|
|
172
|
+
await persistModels(compatibleContext, models);
|
|
173
|
+
} catch {
|
|
174
|
+
// The fresh catalog remains usable when its persistence fails.
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
return models;
|
|
178
|
+
} catch (error) {
|
|
179
|
+
// An in-flight cancellation belongs to the host; do not disguise it as a successful refresh.
|
|
180
|
+
if (context.signal?.aborted) throw error;
|
|
181
|
+
return restoreAfterFailedRefresh(compatibleContext);
|
|
182
|
+
}
|
|
183
|
+
}
|
|
@@ -0,0 +1,355 @@
|
|
|
1
|
+
import { META_API_BASE_URL, META_PROVIDER_ID } from "./constants.ts";
|
|
2
|
+
import { asRecord } from "./http.ts";
|
|
3
|
+
import { gateMuseMaxEffort } from "./muse-policy.ts";
|
|
4
|
+
import type {
|
|
5
|
+
MetaEnv,
|
|
6
|
+
MetaProviderModel,
|
|
7
|
+
MetaResponsesCompat,
|
|
8
|
+
} from "./types.ts";
|
|
9
|
+
|
|
10
|
+
const DEFAULT_CONTEXT_WINDOW = 1_048_576;
|
|
11
|
+
const DEFAULT_MAX_TOKENS = 256_000;
|
|
12
|
+
const STANDARD_COST = {
|
|
13
|
+
input: 1.25,
|
|
14
|
+
output: 4.25,
|
|
15
|
+
cacheRead: 0.15,
|
|
16
|
+
cacheWrite: 0,
|
|
17
|
+
};
|
|
18
|
+
const CONTRIBUTOR_COST = {
|
|
19
|
+
input: 0.1,
|
|
20
|
+
output: 0.2,
|
|
21
|
+
cacheRead: 0.002,
|
|
22
|
+
cacheWrite: 0,
|
|
23
|
+
};
|
|
24
|
+
const UNKNOWN_MODEL_COST = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 };
|
|
25
|
+
const THINKING_LEVELS = [
|
|
26
|
+
"minimal",
|
|
27
|
+
"low",
|
|
28
|
+
"medium",
|
|
29
|
+
"high",
|
|
30
|
+
"xhigh",
|
|
31
|
+
"max",
|
|
32
|
+
] as const;
|
|
33
|
+
type ThinkingLevelMap = NonNullable<MetaProviderModel["thinkingLevelMap"]>;
|
|
34
|
+
|
|
35
|
+
/** Data-only definitions; callers always receive fresh model objects. */
|
|
36
|
+
const FALLBACK_DEFINITIONS = [
|
|
37
|
+
{
|
|
38
|
+
id: "muse-spark-1.3",
|
|
39
|
+
name: "Muse Spark 1.3",
|
|
40
|
+
cost: STANDARD_COST,
|
|
41
|
+
max: true,
|
|
42
|
+
},
|
|
43
|
+
{
|
|
44
|
+
id: "muse-spark-1.3-contributor",
|
|
45
|
+
name: "Muse Spark 1.3 Contributor",
|
|
46
|
+
cost: CONTRIBUTOR_COST,
|
|
47
|
+
max: false,
|
|
48
|
+
},
|
|
49
|
+
{
|
|
50
|
+
id: "muse-spark-1.2",
|
|
51
|
+
name: "Muse Spark 1.2",
|
|
52
|
+
cost: STANDARD_COST,
|
|
53
|
+
max: false,
|
|
54
|
+
},
|
|
55
|
+
{
|
|
56
|
+
id: "muse-spark-1.2-contributor",
|
|
57
|
+
name: "Muse Spark 1.2 Contributor",
|
|
58
|
+
cost: CONTRIBUTOR_COST,
|
|
59
|
+
max: false,
|
|
60
|
+
},
|
|
61
|
+
{
|
|
62
|
+
id: "muse-spark-1.1",
|
|
63
|
+
name: "Muse Spark 1.1",
|
|
64
|
+
cost: STANDARD_COST,
|
|
65
|
+
max: false,
|
|
66
|
+
},
|
|
67
|
+
] as const;
|
|
68
|
+
|
|
69
|
+
function nonemptyString(value: unknown): string | undefined {
|
|
70
|
+
return typeof value === "string" && value.trim() ? value.trim() : undefined;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function positiveInteger(value: unknown, fallback: number): number {
|
|
74
|
+
return typeof value === "number" && Number.isSafeInteger(value) && value > 0
|
|
75
|
+
? value
|
|
76
|
+
: fallback;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function numericCost(value: unknown, fallback: number): number {
|
|
80
|
+
const number =
|
|
81
|
+
typeof value === "number"
|
|
82
|
+
? value
|
|
83
|
+
: typeof value === "string" && value.trim()
|
|
84
|
+
? Number(value)
|
|
85
|
+
: Number.NaN;
|
|
86
|
+
return Number.isFinite(number) && number >= 0 ? number : fallback;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function defaultThinkingLevels(max: boolean): ThinkingLevelMap {
|
|
90
|
+
return {
|
|
91
|
+
off: null,
|
|
92
|
+
minimal: "minimal",
|
|
93
|
+
low: "low",
|
|
94
|
+
medium: "medium",
|
|
95
|
+
high: "high",
|
|
96
|
+
xhigh: "xhigh",
|
|
97
|
+
max: max ? "max" : null,
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function displayName(id: string): string {
|
|
102
|
+
return id
|
|
103
|
+
.split("-")
|
|
104
|
+
.map((part) => part.charAt(0).toUpperCase() + part.slice(1))
|
|
105
|
+
.join(" ");
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function defaultModel(id: string): MetaProviderModel {
|
|
109
|
+
const definition = FALLBACK_DEFINITIONS.find((model) => model.id === id);
|
|
110
|
+
const cost = definition?.cost ?? UNKNOWN_MODEL_COST;
|
|
111
|
+
return {
|
|
112
|
+
id,
|
|
113
|
+
name: definition?.name ?? displayName(id),
|
|
114
|
+
reasoning: true,
|
|
115
|
+
thinkingLevelMap: defaultThinkingLevels(definition?.max ?? false),
|
|
116
|
+
input: definition ? ["text", "image"] : ["text"],
|
|
117
|
+
cost: { ...cost },
|
|
118
|
+
contextWindow: DEFAULT_CONTEXT_WINDOW,
|
|
119
|
+
maxTokens: DEFAULT_MAX_TOKENS,
|
|
120
|
+
compat: { supportsReasoningEffort: true, supportsToolSearch: true },
|
|
121
|
+
};
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
export function fallbackModels(
|
|
125
|
+
env: MetaEnv = process.env,
|
|
126
|
+
): MetaProviderModel[] {
|
|
127
|
+
return FALLBACK_DEFINITIONS.map(({ id }) =>
|
|
128
|
+
gateMuseMaxEffort(defaultModel(id), env),
|
|
129
|
+
);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
export function metaFallbackCost(
|
|
133
|
+
modelId: string,
|
|
134
|
+
): MetaProviderModel["cost"] | undefined {
|
|
135
|
+
const definition = FALLBACK_DEFINITIONS.find((model) => model.id === modelId);
|
|
136
|
+
return definition ? { ...definition.cost } : undefined;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function modelInput(
|
|
140
|
+
value: unknown,
|
|
141
|
+
fallback: MetaProviderModel["input"],
|
|
142
|
+
): MetaProviderModel["input"] {
|
|
143
|
+
if (!Array.isArray(value)) return [...fallback];
|
|
144
|
+
return value.includes("image") ? ["text", "image"] : ["text"];
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function catalogThinkingLevels(
|
|
148
|
+
variants: unknown,
|
|
149
|
+
fallback: ThinkingLevelMap,
|
|
150
|
+
): ThinkingLevelMap {
|
|
151
|
+
const entries = asRecord(variants);
|
|
152
|
+
const map: ThinkingLevelMap = { ...fallback };
|
|
153
|
+
for (const level of THINKING_LEVELS) {
|
|
154
|
+
const effort = nonemptyString(asRecord(entries?.[level])?.reasoningEffort);
|
|
155
|
+
if (effort !== undefined) map[level] = effort;
|
|
156
|
+
}
|
|
157
|
+
return map;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/** Validate each remote field independently so one malformed entry cannot discard the catalog. */
|
|
161
|
+
export function toProviderModels(catalog: unknown): MetaProviderModel[] {
|
|
162
|
+
const data = asRecord(catalog)?.data;
|
|
163
|
+
if (!Array.isArray(data)) return [];
|
|
164
|
+
const models: MetaProviderModel[] = [];
|
|
165
|
+
const seen = new Set<string>();
|
|
166
|
+
for (const value of data) {
|
|
167
|
+
const entry = asRecord(value);
|
|
168
|
+
const id = nonemptyString(entry?.id);
|
|
169
|
+
if (!id || seen.has(id)) continue;
|
|
170
|
+
const metadata = asRecord(asRecord(entry?.metadata)?.["muse-code"]);
|
|
171
|
+
if (metadata?.is_hidden === true) continue;
|
|
172
|
+
const fallback = defaultModel(id);
|
|
173
|
+
const name = nonemptyString(metadata?.name);
|
|
174
|
+
const cost = asRecord(metadata?.cost);
|
|
175
|
+
const limit = asRecord(metadata?.limit);
|
|
176
|
+
models.push(
|
|
177
|
+
gateMuseMaxEffort({
|
|
178
|
+
...fallback,
|
|
179
|
+
name: name && name !== id ? name : fallback.name,
|
|
180
|
+
reasoning:
|
|
181
|
+
typeof metadata?.reasoning === "boolean"
|
|
182
|
+
? metadata.reasoning
|
|
183
|
+
: fallback.reasoning,
|
|
184
|
+
thinkingLevelMap: catalogThinkingLevels(
|
|
185
|
+
metadata?.variants,
|
|
186
|
+
fallback.thinkingLevelMap ?? {},
|
|
187
|
+
),
|
|
188
|
+
input: modelInput(
|
|
189
|
+
asRecord(metadata?.modalities)?.input,
|
|
190
|
+
fallback.input,
|
|
191
|
+
),
|
|
192
|
+
cost: {
|
|
193
|
+
input: numericCost(cost?.input, fallback.cost.input),
|
|
194
|
+
output: numericCost(cost?.output, fallback.cost.output),
|
|
195
|
+
cacheRead: numericCost(cost?.cached, fallback.cost.cacheRead),
|
|
196
|
+
cacheWrite: 0,
|
|
197
|
+
},
|
|
198
|
+
contextWindow: positiveInteger(limit?.context, fallback.contextWindow),
|
|
199
|
+
maxTokens: positiveInteger(limit?.output, fallback.maxTokens),
|
|
200
|
+
}),
|
|
201
|
+
);
|
|
202
|
+
seen.add(id);
|
|
203
|
+
}
|
|
204
|
+
return models;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
function storedThinkingLevels(
|
|
208
|
+
value: unknown,
|
|
209
|
+
fallback: ThinkingLevelMap,
|
|
210
|
+
): ThinkingLevelMap {
|
|
211
|
+
const entries = asRecord(value);
|
|
212
|
+
const map: ThinkingLevelMap = { ...fallback };
|
|
213
|
+
for (const level of ["off", ...THINKING_LEVELS] as const) {
|
|
214
|
+
if (entries?.[level] === null) map[level] = null;
|
|
215
|
+
else {
|
|
216
|
+
const effort = nonemptyString(entries?.[level]);
|
|
217
|
+
if (effort !== undefined) map[level] = effort;
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
return map;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
const COMPAT_BOOLEAN_KEYS = [
|
|
224
|
+
"supportsReasoningEffort",
|
|
225
|
+
"supportsToolSearch",
|
|
226
|
+
"supportsDeveloperRole",
|
|
227
|
+
"supportsMidConvoSystemMessages",
|
|
228
|
+
"supportsLongCacheRetention",
|
|
229
|
+
"supportsStrictMode",
|
|
230
|
+
"supportsOpenAIGrammarTools",
|
|
231
|
+
"supportsAdditionalTools",
|
|
232
|
+
"supportsExplicitPromptCacheMode",
|
|
233
|
+
"supportsMaxOutputTokens",
|
|
234
|
+
] as const satisfies readonly (keyof MetaResponsesCompat)[];
|
|
235
|
+
|
|
236
|
+
function storedCompat(value: unknown): MetaResponsesCompat {
|
|
237
|
+
const entries = asRecord(value);
|
|
238
|
+
const compat: MetaResponsesCompat = {
|
|
239
|
+
supportsReasoningEffort: true,
|
|
240
|
+
supportsToolSearch: true,
|
|
241
|
+
};
|
|
242
|
+
for (const key of COMPAT_BOOLEAN_KEYS) {
|
|
243
|
+
if (typeof entries?.[key] === "boolean") compat[key] = entries[key];
|
|
244
|
+
}
|
|
245
|
+
const sessionAffinity = entries?.sessionAffinityFormat;
|
|
246
|
+
if (
|
|
247
|
+
sessionAffinity === "openai" ||
|
|
248
|
+
sessionAffinity === "openai-nosession" ||
|
|
249
|
+
sessionAffinity === "openrouter"
|
|
250
|
+
) {
|
|
251
|
+
compat.sessionAffinityFormat = sessionAffinity;
|
|
252
|
+
}
|
|
253
|
+
return compat;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
function storedHeaders(value: unknown): Record<string, string> | undefined {
|
|
257
|
+
const entries = asRecord(value);
|
|
258
|
+
if (!entries) return undefined;
|
|
259
|
+
const headers = Object.fromEntries(
|
|
260
|
+
Object.entries(entries).filter(
|
|
261
|
+
(entry): entry is [string, string] => typeof entry[1] === "string",
|
|
262
|
+
),
|
|
263
|
+
);
|
|
264
|
+
return Object.keys(headers).length ? headers : undefined;
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
type CachedCostTier = Pick<
|
|
268
|
+
MetaProviderModel["cost"],
|
|
269
|
+
"input" | "output" | "cacheRead" | "cacheWrite"
|
|
270
|
+
> & {
|
|
271
|
+
inputTokensAbove: number;
|
|
272
|
+
};
|
|
273
|
+
type CachedCost = MetaProviderModel["cost"] & { tiers?: CachedCostTier[] };
|
|
274
|
+
|
|
275
|
+
function storedCost(
|
|
276
|
+
value: unknown,
|
|
277
|
+
fallback: MetaProviderModel["cost"],
|
|
278
|
+
): CachedCost {
|
|
279
|
+
const entries = asRecord(value);
|
|
280
|
+
const cost: CachedCost = {
|
|
281
|
+
input: numericCost(entries?.input, fallback.input),
|
|
282
|
+
output: numericCost(entries?.output, fallback.output),
|
|
283
|
+
cacheRead: numericCost(entries?.cacheRead, fallback.cacheRead),
|
|
284
|
+
cacheWrite: numericCost(entries?.cacheWrite, fallback.cacheWrite),
|
|
285
|
+
};
|
|
286
|
+
if (Array.isArray(entries?.tiers)) {
|
|
287
|
+
const tiers: CachedCostTier[] = [];
|
|
288
|
+
for (const valueTier of entries.tiers) {
|
|
289
|
+
const tier = asRecord(valueTier);
|
|
290
|
+
const inputTokensAbove = tier?.inputTokensAbove;
|
|
291
|
+
if (
|
|
292
|
+
typeof inputTokensAbove !== "number" ||
|
|
293
|
+
!Number.isSafeInteger(inputTokensAbove) ||
|
|
294
|
+
inputTokensAbove < 0
|
|
295
|
+
)
|
|
296
|
+
continue;
|
|
297
|
+
const rates = {
|
|
298
|
+
input: numericCost(tier?.input, Number.NaN),
|
|
299
|
+
output: numericCost(tier?.output, Number.NaN),
|
|
300
|
+
cacheRead: numericCost(tier?.cacheRead, Number.NaN),
|
|
301
|
+
cacheWrite: numericCost(tier?.cacheWrite, Number.NaN),
|
|
302
|
+
};
|
|
303
|
+
if (Object.values(rates).every(Number.isFinite))
|
|
304
|
+
tiers.push({ ...rates, inputTokensAbove });
|
|
305
|
+
}
|
|
306
|
+
if (tiers.length > 0) cost.tiers = tiers;
|
|
307
|
+
}
|
|
308
|
+
return cost;
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
/** Cache contents cross the same untrusted-data boundary as the remote catalog. */
|
|
312
|
+
export function restoreProviderModels(value: unknown): MetaProviderModel[] {
|
|
313
|
+
if (!Array.isArray(value)) return [];
|
|
314
|
+
const models: MetaProviderModel[] = [];
|
|
315
|
+
const seen = new Set<string>();
|
|
316
|
+
for (const valueEntry of value) {
|
|
317
|
+
const entry = asRecord(valueEntry);
|
|
318
|
+
if (
|
|
319
|
+
entry?.provider !== META_PROVIDER_ID ||
|
|
320
|
+
entry.api !== "openai-responses"
|
|
321
|
+
)
|
|
322
|
+
continue;
|
|
323
|
+
const id = nonemptyString(entry.id);
|
|
324
|
+
if (!id || seen.has(id)) continue;
|
|
325
|
+
const fallback = defaultModel(id);
|
|
326
|
+
models.push(
|
|
327
|
+
gateMuseMaxEffort({
|
|
328
|
+
...fallback,
|
|
329
|
+
name: nonemptyString(entry.name) ?? fallback.name,
|
|
330
|
+
api: "openai-responses",
|
|
331
|
+
// The extension only persists the direct endpoint; a cached URL must never redirect the key.
|
|
332
|
+
baseUrl: META_API_BASE_URL,
|
|
333
|
+
reasoning:
|
|
334
|
+
typeof entry.reasoning === "boolean"
|
|
335
|
+
? entry.reasoning
|
|
336
|
+
: fallback.reasoning,
|
|
337
|
+
thinkingLevelMap: storedThinkingLevels(
|
|
338
|
+
entry.thinkingLevelMap,
|
|
339
|
+
fallback.thinkingLevelMap ?? {},
|
|
340
|
+
),
|
|
341
|
+
input: modelInput(entry.input, fallback.input),
|
|
342
|
+
cost: storedCost(entry.cost, fallback.cost),
|
|
343
|
+
contextWindow: positiveInteger(
|
|
344
|
+
entry.contextWindow,
|
|
345
|
+
fallback.contextWindow,
|
|
346
|
+
),
|
|
347
|
+
maxTokens: positiveInteger(entry.maxTokens, fallback.maxTokens),
|
|
348
|
+
headers: storedHeaders(entry.headers),
|
|
349
|
+
compat: storedCompat(entry.compat),
|
|
350
|
+
}),
|
|
351
|
+
);
|
|
352
|
+
seen.add(id);
|
|
353
|
+
}
|
|
354
|
+
return models;
|
|
355
|
+
}
|