clauderipple 0.6.0 → 0.8.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.
Files changed (45) hide show
  1. package/CHANGELOG.md +66 -0
  2. package/README.ko.md +43 -2
  3. package/README.md +60 -4
  4. package/dist/app/package.json +1 -1
  5. package/dist/cli/src/claude-auth.js +35 -1
  6. package/dist/cli/src/codex.js +23 -0
  7. package/dist/cli/src/hooks/agent-title.js +58 -29
  8. package/dist/cli/src/index.js +89 -1
  9. package/dist/cli/src/runtime.js +3 -0
  10. package/dist/cli/src/settings.js +49 -0
  11. package/dist/mod/.claude-plugin/plugin.json +1 -0
  12. package/dist/mod/hooks/hooks.json +1 -0
  13. package/dist/mod/hooks/register.tsx +309 -0
  14. package/dist/mod/hooks/worker.ts +49 -0
  15. package/dist/mod/tsconfig.json +3 -0
  16. package/dist/mod/types/index.d.ts +30 -0
  17. package/dist/router/src/admin.js +497 -34
  18. package/dist/router/src/agents.js +50 -9
  19. package/dist/router/src/catalog.js +80 -0
  20. package/dist/router/src/compat.js +27 -1
  21. package/dist/router/src/config.js +33 -0
  22. package/dist/router/src/index.js +13 -1
  23. package/dist/router/src/ingress/server.js +3 -2
  24. package/dist/router/src/picker.js +33 -2
  25. package/dist/router/src/presets.js +2 -1
  26. package/dist/router/src/providers/chatgpt/index.js +154 -15
  27. package/dist/router/src/providers/chatgpt/translate.js +34 -6
  28. package/dist/router/src/providers/google/accounts.js +345 -0
  29. package/dist/router/src/providers/google/antigravity.js +392 -0
  30. package/dist/router/src/providers/google/index.js +617 -0
  31. package/dist/router/src/providers/google/login.js +249 -0
  32. package/dist/router/src/providers/google/thoughtSignatures.js +47 -0
  33. package/dist/router/src/providers/google/translate.js +539 -0
  34. package/dist/router/src/providers/google/transport.js +118 -0
  35. package/dist/router/src/providers/openai/translate.js +3 -3
  36. package/dist/router/src/proxy.js +99 -1
  37. package/dist/router/src/requestlog.js +2 -0
  38. package/dist/router/src/routing.js +22 -0
  39. package/dist/router/src/version.js +1 -1
  40. package/dist/ui/app.js +436 -44
  41. package/dist/ui/i18n.js +88 -4
  42. package/dist/ui/index.html +10 -0
  43. package/dist/ui/style.css +4 -0
  44. package/docs/ARCHITECTURE.md +300 -3
  45. package/package.json +1 -1
@@ -0,0 +1,392 @@
1
+ // Google Antigravity / Cloud Code Assist (CCA): the wire facts, model-id resolution and the
2
+ // conversation session id this router uses. Kept apart from the account store (accounts.ts) and the
3
+ // OAuth session (login.ts) so each can be read and tested on its own.
4
+ //
5
+ // Every fact here is taken from the reference implementation opencodex (GitHub lidge-jun/opencodex,
6
+ // read 2026-10-01 as a behavioral spec — no code copied; the project rule forbids copying it). Each
7
+ // constant names its source file:line in a comment. **Nothing here is measured against a live
8
+ // Google endpoint** — this environment has no Antigravity subscription, so the whole file is
9
+ // doc/source-derived and unmeasured. That is recorded in docs/ARCHITECTURE.md §4d.
10
+ import crypto from "node:crypto";
11
+ // ── OAuth + Cloud Code Assist endpoints ─────────────────────────────────────────────────────────
12
+ /**
13
+ * The public OAuth client embedded in the Antigravity desktop client. These are client identifiers,
14
+ * not user secrets: opencodex ships them in the clear and they are overridable by environment there.
15
+ * Source: opencodex `src/oauth/google-antigravity.ts:17-38`.
16
+ */
17
+ export const GOOGLE_OAUTH = {
18
+ clientId: "1071006060591-tmhssin2h21lcre235vtolojh4g403ep.apps.googleusercontent.com",
19
+ clientSecret: "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",
20
+ authorizeUrl: "https://accounts.google.com/o/oauth2/v2/auth",
21
+ tokenUrl: "https://oauth2.googleapis.com/token",
22
+ userinfoUrl: "https://www.googleapis.com/oauth2/v2/userinfo",
23
+ scopes: [
24
+ "https://www.googleapis.com/auth/cloud-platform",
25
+ "https://www.googleapis.com/auth/userinfo.email",
26
+ "https://www.googleapis.com/auth/userinfo.profile",
27
+ "https://www.googleapis.com/auth/cclog",
28
+ "https://www.googleapis.com/auth/experimentsandconfigs",
29
+ ],
30
+ /** Project discovery (`loadCodeAssist`) is served by the prod host. */
31
+ prodApi: "https://cloudcode-pa.googleapis.com",
32
+ /** Streaming and `onboardUser` use the daily host. Source: opencodex `src/oauth/google-antigravity.ts:24-25`. */
33
+ dailyApi: "https://daily-cloudcode-pa.googleapis.com",
34
+ apiVersion: "v1internal",
35
+ /** Preferred loopback port; the callback path is fixed. Source: opencodex `src/oauth/google-antigravity.ts:34-35`. */
36
+ port: 51121,
37
+ callbackPath: "/callback",
38
+ /** Refresh this long before the access token expires. Source: opencodex `REFRESH_SKEW_MS`. */
39
+ refreshLeadMs: 5 * 60 * 1000,
40
+ /** How long a sign-in may stay open. Same as the other sign-in flows. */
41
+ timeoutMs: 5 * 60 * 1000,
42
+ requestTimeoutMs: 30_000,
43
+ };
44
+ /**
45
+ * The terms warning the CLI prints once and the admin API returns in a `warning` field. Facts only:
46
+ * Google's Antigravity terms (clause 6) and the Gemini CLI FAQ state that using these credentials
47
+ * from a third-party client is a violation, and Google blocked a large number of accounts in
48
+ * February 2026. ClaudeRipple is not affiliated with Google.
49
+ */
50
+ export const ANTIGRAVITY_WARNING = "Google's Antigravity terms (section 6) and the Gemini CLI FAQ say that using these credentials " +
51
+ "from a client other than Google's own is not permitted, and Google blocked a large number of " +
52
+ "accounts used this way in February 2026. ClaudeRipple is not affiliated with Google. Sign in only " +
53
+ "if you accept that risk.";
54
+ /** Antigravity IDE version, mirrored so the request fingerprint matches the token's client. */
55
+ export const ANTIGRAVITY_IDE_VERSION = "2.5.5";
56
+ /**
57
+ * The User-Agent the real Antigravity IDE sends. The CCA backend gates newer agent models on the
58
+ * `antigravity/ide/...` client family and answers 404 to CLI-shaped UAs even with a valid OAuth
59
+ * token. Source: opencodex `src/adapters/client-fingerprint.ts:44-65`, decompiled from the 2.5.5
60
+ * language server.
61
+ */
62
+ export function antigravityUserAgent() {
63
+ const override = process.env.CLAUDERIPPLE_ANTIGRAVITY_USER_AGENT?.trim() || process.env.GOOGLE_ANTIGRAVITY_USER_AGENT?.trim();
64
+ if (override)
65
+ return override;
66
+ const [osType, arch] = "windows/amd64".split("/");
67
+ return `antigravity/ide/${ANTIGRAVITY_IDE_VERSION} (os_type=${osType}; arch=${arch}; aidev_client; auth_method=oauth)`;
68
+ }
69
+ // ── Model id resolution ─────────────────────────────────────────────────────────────────────────
70
+ /**
71
+ * Normalise a Claude Code effort onto Cloud Code Assist's three thinking levels. `xhigh`, `max` and
72
+ * `ultra` all clamp to `high`; `none`/`minimal` to `low`. `minimal` is not itself a CCA level.
73
+ * Source: opencodex `src/providers/antigravity-models.ts:resolveAntigravityThinkingLevel`.
74
+ */
75
+ export function resolveAntigravityThinkingLevel(effort) {
76
+ switch (effort) {
77
+ case "none":
78
+ case "minimal":
79
+ case "low": return "low";
80
+ case "medium": return "medium";
81
+ case "high":
82
+ case "xhigh":
83
+ case "max":
84
+ case "ultra": return "high";
85
+ default: return undefined;
86
+ }
87
+ }
88
+ /**
89
+ * Base models whose every effort rides on a different wire id that already names the tier. Here the
90
+ * suffix IS the effort, so no `thinkingConfig` is sent beside it. Source: opencodex
91
+ * `ANTIGRAVITY_EFFORT_WIRE_MAP` / `ANTIGRAVITY_SUFFIX_TIER_MODELS`.
92
+ */
93
+ const SUFFIX_TIER_MODELS = {
94
+ "gemini-3.8-flash": {
95
+ wire: { low: "gemini-3.8-flash-low", medium: "gemini-3.8-flash-medium", high: "gemini-3.8-flash-high" },
96
+ default: "medium",
97
+ },
98
+ };
99
+ /**
100
+ * Base models whose efforts map to wire ids but where a `thinkingConfig.thinkingLevel` also travels
101
+ * (the high rung `gemini-pro-agent` carries no tier in its name). Source: opencodex
102
+ * `ANTIGRAVITY_EFFORT_WIRE_MAP` / `ANTIGRAVITY_MODEL_EFFORTS`.
103
+ */
104
+ const EFFORT_WIRE_MODELS = {
105
+ "gemini-3.1-pro": { wire: { low: "gemini-3.1-pro-low", high: "gemini-pro-agent" }, default: "high" },
106
+ };
107
+ /**
108
+ * Base models whose effort rides on a single `-tiered` wire id via `thinkingLevel`. Source:
109
+ * opencodex `ANTIGRAVITY_THINKING_LEVEL_MODELS` / `ANTIGRAVITY_PICKER_TO_WIRE`.
110
+ */
111
+ const THINKING_LEVEL_MODELS = {
112
+ "gemini-3.7-flash": { wire: "gemini-3.7-flash-tiered", default: "medium" },
113
+ };
114
+ /**
115
+ * Resolve a Claude Code model id onto the Cloud Code Assist wire id and thinking level. A base id
116
+ * (`gemini-3.8-flash`) resolves to its tier wire id; an id already naming a tier is forwarded as it
117
+ * is. Unknown ids pass through untouched so a model Google adds tomorrow is still directly routable.
118
+ */
119
+ export function resolveAntigravityWireModel(model, effort) {
120
+ const level = resolveAntigravityThinkingLevel(effort);
121
+ const suffix = SUFFIX_TIER_MODELS[model];
122
+ if (suffix) {
123
+ const wire = (level && suffix.wire[level]) || suffix.wire[suffix.default];
124
+ // The suffix names the tier; sending `thinkingLevel` beside it would state the effort twice.
125
+ return { wire, known: true };
126
+ }
127
+ const mapped = EFFORT_WIRE_MODELS[model];
128
+ if (mapped) {
129
+ const wire = (level && mapped.wire[level]) || mapped.wire[mapped.default];
130
+ return { wire, ...(level ? { thinkingLevel: level } : {}), known: true };
131
+ }
132
+ const tiered = THINKING_LEVEL_MODELS[model];
133
+ if (tiered) {
134
+ return { wire: tiered.wire, thinkingLevel: (level ?? tiered.default), known: true };
135
+ }
136
+ // Claude models on Antigravity take effort through thinkingConfig only, no suffix — CCA validates
137
+ // the field against Google's ThinkingLevel enum. Source: opencodex rule 4.
138
+ if (/^claude-/.test(model))
139
+ return { wire: model, ...(level ? { thinkingLevel: level } : {}), known: true };
140
+ return { wire: model, known: false };
141
+ }
142
+ /**
143
+ * The conversation's Cloud Code Assist session id. The real client puts one stable id on every turn
144
+ * of a conversation so provider-side replay can associate a thoughtSignature with the call that
145
+ * produced it; an unstable id loses that association. We derive it from `conversationKey`, which the
146
+ * router already keeps stable per conversation (and identical to the prompt-cache key), and shape it
147
+ * as the numeric string the wire carries — `sha256(key)` → BigEndian uint64 masked to 63 bits, with a
148
+ * leading "-". Source: opencodex `antigravitySessionId` (CLIProxyAPI `generateStableSessionID`).
149
+ */
150
+ export function antigravitySessionId(conversationKey) {
151
+ const digest = crypto.createHash("sha256").update(conversationKey, "utf8").digest();
152
+ const masked = digest.readBigUInt64BE(0) & 0x7fffffffffffffffn;
153
+ return `-${masked.toString()}`;
154
+ }
155
+ /** A fresh per-request id, as the envelope's `requestId`. Source: opencodex `agent-${uuid}`. */
156
+ export function antigravityRequestId() {
157
+ return `agent-${crypto.randomUUID()}`;
158
+ }
159
+ // ── Model catalogue ─────────────────────────────────────────────────────────────────────────────
160
+ /**
161
+ * Context windows per model, from CCA's own `maxTokens` for the wire ids. Source: opencodex
162
+ * `ANTIGRAVITY_WIRE_MODEL_CONTEXT_WINDOWS`.
163
+ */
164
+ const CONTEXT_WINDOWS = {
165
+ "gemini-3.8-flash": 1_048_576,
166
+ "gemini-3.7-flash": 1_048_576,
167
+ "gemini-3.1-pro": 1_048_576,
168
+ "gemini-3.1-flash-image": 1_048_576,
169
+ "claude-sonnet-4-6": 250_000,
170
+ "claude-opus-4-6-thinking": 250_000,
171
+ "gpt-oss-120b-medium": 131_072,
172
+ };
173
+ /**
174
+ * The effort ladder per collapsed base model, for the GUI. The suffix-tier model's rungs are wire
175
+ * ids, so the GUI shows them as one entry with a ladder rather than three rows (opencodex does the
176
+ * same collapsing). Source: opencodex `ANTIGRAVITY_MODEL_EFFORTS`.
177
+ */
178
+ const EFFORT_LEVELS = {
179
+ "gemini-3.8-flash": ["low", "medium", "high"],
180
+ "gemini-3.7-flash": ["low", "medium", "high"],
181
+ "gemini-3.1-pro": ["low", "high"],
182
+ "claude-sonnet-4-6": ["low", "medium", "high"],
183
+ "claude-opus-4-6-thinking": ["low", "medium", "high"],
184
+ };
185
+ /** The collapsed base models the CCA backend serves. Source: opencodex `ANTIGRAVITY_MODELS`. */
186
+ export const ANTIGRAVITY_BASE_MODELS = [
187
+ "gemini-3.8-flash",
188
+ "gemini-3.7-flash",
189
+ "gemini-3.1-pro",
190
+ "gemini-3.1-flash-image",
191
+ "claude-sonnet-4-6",
192
+ "claude-opus-4-6-thinking",
193
+ "gpt-oss-120b-medium",
194
+ ];
195
+ /** The static fallback list, used when `:fetchAvailableModels` cannot be read. */
196
+ export function staticAntigravityModels() {
197
+ return ANTIGRAVITY_BASE_MODELS.map((id) => ({
198
+ id,
199
+ ...(CONTEXT_WINDOWS[id] ? { contextWindow: CONTEXT_WINDOWS[id] } : {}),
200
+ ...(EFFORT_LEVELS[id] ? { effortLevels: [...EFFORT_LEVELS[id]] } : {}),
201
+ }));
202
+ }
203
+ function isBaseModel(id) {
204
+ return ANTIGRAVITY_BASE_MODELS.includes(id);
205
+ }
206
+ /**
207
+ * Collapse a wire id back to the collapsed base id the picker shows, when it names a known tier. The
208
+ * collapsed id is what the static catalogue lists and what `resolveAntigravityWireModel` maps back to
209
+ * a wire id, so the picker shows one row per model and routing is the same whether the list came from
210
+ * discovery or the fallback.
211
+ */
212
+ function collapseWireId(wireId) {
213
+ if (isBaseModel(wireId))
214
+ return wireId;
215
+ const tiered = wireId.endsWith("-tiered") ? wireId.slice(0, -"-tiered".length) : undefined;
216
+ if (tiered && isBaseModel(tiered))
217
+ return tiered;
218
+ const match = /^(.*)-(low|medium|high|max|minimal)$/.exec(wireId);
219
+ if (match && isBaseModel(match[1]))
220
+ return match[1];
221
+ return wireId;
222
+ }
223
+ /**
224
+ * Parse `POST {dailyApi}/v1internal:fetchAvailableModels`. The body groups agent-callable models
225
+ * under `agentModelSorts[].groups[].modelIds`, with tiered Flash ids under `tieredModelIds.flash`, and
226
+ * describes each in `models[wireId]` (`maxTokens`). Source: opencodex
227
+ * `parseAntigravityAvailableModels` (src/providers/antigravity-models.ts:489-560).
228
+ *
229
+ * Returns null when the shape is unreadable, so the caller falls back instead of showing nothing.
230
+ */
231
+ export function parseAvailableModels(payload, limit = 200) {
232
+ if (!payload || typeof payload !== "object" || Array.isArray(payload))
233
+ return null;
234
+ const body = payload;
235
+ const models = body.models;
236
+ if (!models || typeof models !== "object" || Array.isArray(models))
237
+ return null;
238
+ const known = models;
239
+ const available = new Set(Object.keys(known));
240
+ // Collapse each wire id to its base id first, then take the context window and display name from
241
+ // the first wire id that carried that base. A collapsed id is what routing understands.
242
+ const collapsed = new Map();
243
+ const add = (wireId) => {
244
+ if (typeof wireId !== "string" || !available.has(wireId))
245
+ return;
246
+ const id = collapseWireId(wireId);
247
+ if (!collapsed.has(id))
248
+ collapsed.set(id, wireId);
249
+ };
250
+ const sorts = Array.isArray(body.agentModelSorts) ? body.agentModelSorts : [];
251
+ for (const sort of sorts) {
252
+ const groups = sort && typeof sort === "object" ? sort.groups : undefined;
253
+ if (!Array.isArray(groups))
254
+ continue;
255
+ for (const group of groups) {
256
+ const ids = group && typeof group === "object" ? group.modelIds : undefined;
257
+ if (Array.isArray(ids))
258
+ for (const id of ids)
259
+ add(id);
260
+ }
261
+ }
262
+ const tiered = body.tieredModelIds && typeof body.tieredModelIds === "object" ? body.tieredModelIds.flash : undefined;
263
+ if (Array.isArray(tiered))
264
+ for (const id of tiered)
265
+ add(id);
266
+ if (collapsed.size === 0)
267
+ return null;
268
+ const out = [];
269
+ for (const [id, wireId] of collapsed) {
270
+ const info = known[wireId] && typeof known[wireId] === "object" ? known[wireId] : undefined;
271
+ const contextWindow = typeof info?.maxTokens === "number" && Number.isFinite(info.maxTokens) && info.maxTokens > 0 ? Math.floor(info.maxTokens) : CONTEXT_WINDOWS[id];
272
+ out.push({
273
+ id,
274
+ ...(typeof info?.displayName === "string" && info.displayName ? { name: info.displayName } : {}),
275
+ ...(contextWindow ? { contextWindow } : {}),
276
+ ...(EFFORT_LEVELS[id] ? { effortLevels: [...EFFORT_LEVELS[id]] } : {}),
277
+ });
278
+ if (out.length >= limit)
279
+ break;
280
+ }
281
+ return out.length > 0 ? out : null;
282
+ }
283
+ function extractProjectId(data) {
284
+ if (!data)
285
+ return undefined;
286
+ for (const key of ["cloudaicompanionProject", "projectId", "project"]) {
287
+ const value = data[key];
288
+ if (typeof value === "string" && value.length > 0)
289
+ return value;
290
+ if (value && typeof value === "object" && typeof value.id === "string")
291
+ return value.id;
292
+ }
293
+ return undefined;
294
+ }
295
+ function signalFor(outer) {
296
+ const timeout = AbortSignal.timeout(GOOGLE_OAUTH.requestTimeoutMs);
297
+ return outer ? AbortSignal.any([outer, timeout]) : timeout;
298
+ }
299
+ /**
300
+ * Discover the account's Cloud Code Assist project: `loadCodeAssist` on the prod host, then
301
+ * `onboardUser` polling on the daily host when the account has none yet. Returns undefined when
302
+ * neither yields one. Source: opencodex `loadCodeAssistProject` / `onboardProject`.
303
+ */
304
+ export async function discoverProject(accessToken, fetchImpl = fetch, signal, sleep = (ms) => new Promise((r) => setTimeout(r, ms))) {
305
+ const loaded = await loadCodeAssistProject(accessToken, fetchImpl, signal);
306
+ if (loaded)
307
+ return loaded;
308
+ return onboardProject(accessToken, fetchImpl, signal, sleep);
309
+ }
310
+ async function loadCodeAssistProject(accessToken, fetchImpl, signal) {
311
+ try {
312
+ const response = await fetchImpl(`${GOOGLE_OAUTH.prodApi}/${GOOGLE_OAUTH.apiVersion}:loadCodeAssist`, {
313
+ method: "POST",
314
+ headers: { authorization: `Bearer ${accessToken}`, accept: "*/*", "content-type": "application/json", "user-agent": antigravityUserAgent() },
315
+ body: JSON.stringify({ metadata: { ideType: "ANTIGRAVITY" } }),
316
+ signal: signalFor(signal),
317
+ });
318
+ if (!response.ok)
319
+ return undefined;
320
+ return extractProjectId((await response.json().catch(() => undefined)));
321
+ }
322
+ catch {
323
+ return undefined;
324
+ }
325
+ }
326
+ const ONBOARD_ATTEMPTS = 5;
327
+ const ONBOARD_POLL_MS = 2_000;
328
+ async function onboardProject(accessToken, fetchImpl, signal, sleep) {
329
+ for (let attempt = 0; attempt < ONBOARD_ATTEMPTS; attempt++) {
330
+ if (signal?.aborted)
331
+ return undefined;
332
+ let response;
333
+ try {
334
+ response = await fetchImpl(`${GOOGLE_OAUTH.dailyApi}/${GOOGLE_OAUTH.apiVersion}:onboardUser`, {
335
+ method: "POST",
336
+ headers: { authorization: `Bearer ${accessToken}`, accept: "*/*", "content-type": "application/json", "user-agent": antigravityUserAgent() },
337
+ body: JSON.stringify({ tier_id: "free-tier", metadata: { ide_type: "ANTIGRAVITY", ide_name: "antigravity", ide_version: ANTIGRAVITY_IDE_VERSION } }),
338
+ signal: signalFor(signal),
339
+ });
340
+ }
341
+ catch {
342
+ return undefined;
343
+ }
344
+ if (!response.ok) {
345
+ // A transient refusal (429/5xx) is worth polling through; a hard 4xx will not change.
346
+ if (response.status === 429 || response.status >= 500) {
347
+ await sleep(ONBOARD_POLL_MS);
348
+ continue;
349
+ }
350
+ return undefined;
351
+ }
352
+ const data = (await response.json().catch(() => ({})));
353
+ if (data.done === true)
354
+ return extractProjectId(data.response);
355
+ await sleep(ONBOARD_POLL_MS);
356
+ }
357
+ return undefined;
358
+ }
359
+ /** The account email from Google's pinned userinfo endpoint. Never persisted as a token. */
360
+ export async function fetchUserEmail(accessToken, fetchImpl = fetch, signal) {
361
+ try {
362
+ const response = await fetchImpl(GOOGLE_OAUTH.userinfoUrl, {
363
+ method: "GET",
364
+ headers: { authorization: `Bearer ${accessToken}`, accept: "application/json" },
365
+ signal: signalFor(signal),
366
+ });
367
+ if (!response.ok)
368
+ return undefined;
369
+ const body = (await response.json().catch(() => undefined));
370
+ return typeof body?.email === "string" && body.email.length > 0 ? body.email.toLowerCase() : undefined;
371
+ }
372
+ catch {
373
+ return undefined;
374
+ }
375
+ }
376
+ /** The models a CCA account can reach now, or null when the endpoint cannot be read. */
377
+ export async function fetchAvailableModels(accessToken, projectId, fetchImpl = fetch, signal) {
378
+ try {
379
+ const response = await fetchImpl(`${GOOGLE_OAUTH.dailyApi}/${GOOGLE_OAUTH.apiVersion}:fetchAvailableModels`, {
380
+ method: "POST",
381
+ headers: { authorization: `Bearer ${accessToken}`, accept: "*/*", "content-type": "application/json", "user-agent": antigravityUserAgent() },
382
+ body: JSON.stringify({ project: projectId }),
383
+ signal: signalFor(signal),
384
+ });
385
+ if (!response.ok)
386
+ return null;
387
+ return parseAvailableModels(await response.json().catch(() => undefined));
388
+ }
389
+ catch {
390
+ return null;
391
+ }
392
+ }