@specode/pi-antigravity 0.0.0-stage → 0.1.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/src/catalog.ts ADDED
@@ -0,0 +1,276 @@
1
+ import { createHash } from "node:crypto";
2
+ import type { Api, Credential, Model, ModelThinkingLevel, ModelsStoreEntry, RefreshModelsContext } from "@earendil-works/pi-ai";
3
+ import { getApiKey, initializeAccount, parseApiKey, type AntigravityApiKey } from "./auth.ts";
4
+ import { assertOfficialBaseUrl, DEFAULT_BASE_URL, postJson } from "./client.ts";
5
+
6
+ /** A selected runtime, not an assertion about response.modelVersion. */
7
+ export interface CatalogRoute {
8
+ runtimeId: string;
9
+ modelEnum: string;
10
+ thinkingLevel?: "LOW" | "MEDIUM" | "HIGH";
11
+ }
12
+ export interface CatalogModelRoutes {
13
+ default: CatalogRoute;
14
+ levels: Partial<Record<ModelThinkingLevel, CatalogRoute>>;
15
+ }
16
+ export interface AntigravityCatalog {
17
+ models: Model<Api>[];
18
+ routes: Record<string, CatalogModelRoutes>;
19
+ /** Sanitized discovery data sufficient to rebuild the cache, without quota/account data. */
20
+ source: CatalogSource;
21
+ }
22
+ interface CatalogSource {
23
+ models: Record<string, {
24
+ displayName: string;
25
+ model: string;
26
+ maxTokens: number;
27
+ maxOutputTokens: number;
28
+ supportsThinking: boolean;
29
+ /** Raw directory int32, not the generationConfig string enum. */
30
+ thinkingLevel?: number;
31
+ supportsImages: boolean;
32
+ recommended: true;
33
+ }>;
34
+ }
35
+ /** Extension metadata travels through Pi's public ModelsStore; no separate files. */
36
+ export interface AntigravityStoredCatalog extends ModelsStoreEntry {
37
+ antigravity: { schemaVersion: 1; accountKey: string; baseUrl: string; source: CatalogSource };
38
+ }
39
+ export interface CatalogOptions {
40
+ /** Must match the custom streaming API ID used by the parent Provider. */
41
+ api?: Api;
42
+ baseUrl?: string;
43
+ postJson?: typeof postJson;
44
+ now?: () => number;
45
+ refreshIntervalMs?: number;
46
+ }
47
+ /** Pass the parsed JSON bridge; token is only needed to guard accounts without email. */
48
+ export type CatalogOwnership = Pick<AntigravityApiKey, "projectId" | "email" | "ownershipId"> & { token?: string; baseUrl?: string };
49
+ export interface AntigravityCatalogController {
50
+ getModels(): readonly Model<Api>[];
51
+ filterModels(models: readonly Model<Api>[], credential: Credential | undefined): readonly Model<Api>[];
52
+ refreshModels(context: RefreshModelsContext): Promise<void>;
53
+ /** Unsupported explicit effort fails; no generation/effort fallback. */
54
+ resolveRoute(publicId: string, thinkingLevel?: ModelThinkingLevel, ownership?: CatalogOwnership): CatalogRoute;
55
+ /** Guard direct stream calls, which bypass model-picker filtering. */
56
+ assertOwnership(ownership: CatalogOwnership): void;
57
+ }
58
+ const LEVELS: ModelThinkingLevel[] = ["off", "minimal", "low", "medium", "high", "xhigh", "max"];
59
+ const EFFORTS = { low: "LOW", medium: "MEDIUM", high: "HIGH" } as const;
60
+ const REFRESH_INTERVAL_MS = 4 * 60 * 60 * 1000;
61
+ function record(value: unknown): Record<string, unknown> | undefined {
62
+ return value !== null && typeof value === "object" && !Array.isArray(value) ? value as Record<string, unknown> : undefined;
63
+ }
64
+ function positive(value: unknown): value is number {
65
+ return typeof value === "number" && Number.isSafeInteger(value) && value > 0;
66
+ }
67
+ function runtimeId(id: string): boolean {
68
+ return /^[a-z0-9][a-z0-9_.-]{0,199}$/.test(id) && !/^(?:model_|chat_|tab_)/i.test(id);
69
+ }
70
+ function cleanName(value: unknown, fallback: string): string {
71
+ return typeof value === "string" && value.length > 0 && value.length <= 256 && !/[\x00-\x1f\x7f]/.test(value)
72
+ ? value : fallback;
73
+ }
74
+ function invalidCatalog(): Error { return new Error("Antigravity returned an empty, unsupported, or malformed chat catalog."); }
75
+
76
+ /**
77
+ * Parse the models dictionary and curated agentModelSorts.groups[].modelIds.
78
+ * Field names were checked against official CLI 1.5.9 ModelDetails protobuf
79
+ * getters (0x1009419c0 onward), corroborating public upstream discovery code.
80
+ * Proto3 false booleans can be omitted. The handoff captures contain no raw
81
+ * directory replies; static declarations do not prove every account's payload.
82
+ * No fuzzy family merges,
83
+ * placeholder IDs, guessed limits/prices, or synthetic thinking-budget levels.
84
+ * Only explicit low/medium/high suffixes are grouped; unknown efforts are not advertised.
85
+ */
86
+ export function buildAntigravityCatalog(payload: unknown, baseUrl = DEFAULT_BASE_URL, api: Api = "antigravity"): AntigravityCatalog {
87
+ baseUrl = assertOfficialBaseUrl(baseUrl);
88
+ const root = record(payload);
89
+ const raw = record(root?.models);
90
+ if (!raw || Object.keys(raw).length === 0 || Object.keys(raw).length > 4096) throw invalidCatalog();
91
+ const curated = new Set<string>();
92
+ if (root?.agentModelSorts !== undefined) {
93
+ if (!Array.isArray(root.agentModelSorts)) throw invalidCatalog();
94
+ for (const sort of root.agentModelSorts) {
95
+ const groups = record(sort)?.groups;
96
+ if (!Array.isArray(groups)) continue;
97
+ for (const group of groups) {
98
+ const ids = record(group)?.modelIds;
99
+ if (Array.isArray(ids)) for (const id of ids) if (typeof id === "string") curated.add(id);
100
+ }
101
+ }
102
+ } else {
103
+ // Without a curated list, require an explicit recommendation. Do not expose
104
+ // every internal entry merely because its name looks like a chat model.
105
+ for (const [id, value] of Object.entries(raw)) if (record(value)?.recommended === true) curated.add(id);
106
+ }
107
+ const imageIds = new Set(Array.isArray(root?.imageGenerationModelIds) ? root.imageGenerationModelIds : []);
108
+ const source: CatalogSource = { models: Object.create(null) };
109
+ const groups = new Map<string, { name: string; entries: { id: string; info: CatalogSource["models"][string]; route: CatalogRoute; level: ModelThinkingLevel }[] }>();
110
+ for (const id of curated) {
111
+ const info = record(raw[id]);
112
+ if (!runtimeId(id) || !info || imageIds.has(id) || /(?:^|[-_])image(?:[-_]|$)/i.test(id) ||
113
+ info.isInternal === true || info.disabled === true || info.apiProvider === "API_PROVIDER_INTERNAL") continue;
114
+ if (!positive(info.maxTokens) || !positive(info.maxOutputTokens) ||
115
+ (info.supportsThinking !== undefined && typeof info.supportsThinking !== "boolean") ||
116
+ (info.supportsImages !== undefined && typeof info.supportsImages !== "boolean") ||
117
+ typeof info.model !== "string" || !/^MODEL_[A-Z0-9_]+$/.test(info.model)) throw invalidCatalog();
118
+ const suffix = /^(.*)-(low|medium|high)$/.exec(id);
119
+ // The CLI protobuf declares thinking_level as int32, not an enum. Its
120
+ // numeric semantics are not established by these captures. Preserve it,
121
+ // but do not reinterpret it as generationConfig.thinkingLevel.
122
+ if (info.thinkingLevel !== undefined && (typeof info.thinkingLevel !== "number" ||
123
+ !Number.isInteger(info.thinkingLevel) || info.thinkingLevel < -2147483648 || info.thinkingLevel > 2147483647)) throw invalidCatalog();
124
+ const supportsThinking = info.supportsThinking === true;
125
+ if (supportsThinking && !suffix) continue;
126
+ if (!supportsThinking && suffix) throw invalidCatalog();
127
+ const level: ModelThinkingLevel = suffix ? suffix[2] as keyof typeof EFFORTS : "off";
128
+ const publicId = suffix ? suffix[1] : id;
129
+ const name = cleanName(info.displayName ?? info.label ?? info.modelName, publicId).replace(/\s*\((?:low|medium|high)\)\s*$/i, "");
130
+ const normalized: CatalogSource["models"][string] = {
131
+ displayName: name, model: info.model, maxTokens: info.maxTokens, maxOutputTokens: info.maxOutputTokens,
132
+ supportsThinking, ...(typeof info.thinkingLevel === "number" ? { thinkingLevel: info.thinkingLevel } : {}),
133
+ supportsImages: info.supportsImages === true, recommended: true,
134
+ };
135
+ source.models[id] = normalized;
136
+ const route: CatalogRoute = { runtimeId: id, modelEnum: info.model,
137
+ ...(level !== "off" ? { thinkingLevel: EFFORTS[level as keyof typeof EFFORTS] } : {}) };
138
+ let group = groups.get(publicId);
139
+ if (!group) { group = { name, entries: [] }; groups.set(publicId, group); }
140
+ group.entries.push({ id, info: normalized, route, level });
141
+ }
142
+ if (!groups.size) throw invalidCatalog();
143
+ const models: Model<Api>[] = [];
144
+ const routes: Record<string, CatalogModelRoutes> = Object.create(null);
145
+ for (const [id, group] of groups) {
146
+ const levels: CatalogModelRoutes["levels"] = {};
147
+ const reasoning = group.entries.every(entry => entry.info.supportsThinking);
148
+ if (!reasoning && group.entries.some(entry => entry.info.supportsThinking)) throw invalidCatalog();
149
+ for (const entry of group.entries) {
150
+ if (levels[entry.level]) throw invalidCatalog();
151
+ levels[entry.level] = entry.route;
152
+ }
153
+ const defaultRoute = levels.off ?? levels.low ?? levels.medium ?? levels.high;
154
+ if (!defaultRoute) throw invalidCatalog();
155
+ routes[id] = { default: defaultRoute, levels };
156
+ models.push({ type: "chat", id, name: group.name, provider: "antigravity", api, baseUrl, reasoning,
157
+ ...(reasoning ? { thinkingLevelMap: Object.fromEntries(LEVELS.map(level => [level, levels[level] ? level : null])) } : {}),
158
+ input: group.entries.every(entry => entry.info.supportsImages) ? ["text", "image"] : ["text"],
159
+ // Subscription access has no verified per-token billing rate. Zero means
160
+ // unpriced in Pi, not a claim of free API usage or a subscription invoice.
161
+ cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
162
+ contextWindow: Math.min(...group.entries.map(entry => entry.info.maxTokens)),
163
+ maxTokens: Math.min(...group.entries.map(entry => entry.info.maxOutputTokens)),
164
+ });
165
+ }
166
+ return { models, routes, source };
167
+ }
168
+
169
+ /** Stable across access-token refreshes, isolated by account/project and official origin. */
170
+ export function accountCatalogKey(credential: Credential | undefined, baseUrl = DEFAULT_BASE_URL): string | undefined {
171
+ if (credential?.type !== "oauth") return undefined;
172
+ const material = parseApiKey(getApiKey(credential));
173
+ const identity = material.email ? ["email", material.email.toLowerCase()] : ["ownership", material.ownershipId];
174
+ return createHash("sha256").update(JSON.stringify(["antigravity-catalog-v1", assertOfficialBaseUrl(baseUrl), identity, material.projectId])).digest("hex");
175
+ }
176
+
177
+ /**
178
+ * Spread these three Provider methods onto the parent native Provider (do not
179
+ * pass as legacy refreshModels/fetchModels). Pi owns credential refresh and the
180
+ * generation-checked persistence transaction. resolveRoute is used by chat.
181
+ * Offline cache restoration needs the effective stored OAuth credential; without
182
+ * it no account-owned models are hydrated. Errors propagate while the same
183
+ * account's last good catalog survives. Account changes hide the old catalog.
184
+ */
185
+ export function createAntigravityCatalog(options: CatalogOptions = {}): AntigravityCatalogController {
186
+ const baseUrl = assertOfficialBaseUrl(options.baseUrl ?? DEFAULT_BASE_URL);
187
+ const api = options.api ?? "antigravity";
188
+ const now = options.now ?? Date.now;
189
+ const interval = options.refreshIntervalMs ?? REFRESH_INTERVAL_MS;
190
+ if (!Number.isFinite(interval) || interval < 0) throw new Error("Invalid catalog refresh interval.");
191
+ const transport = options.postJson ?? postJson;
192
+ type State = { accountKey: string; baseUrl: string; checkedAt: number; catalog: AntigravityCatalog; owner: { projectId: string; email?: string; ownershipId?: string; tokenDigest: string } };
193
+ let state: State | undefined;
194
+ let generation = 0;
195
+ const digest = (token: string) => createHash("sha256").update(token).digest("hex");
196
+ const assertOwnership = (material: CatalogOwnership) => {
197
+ const owner = state?.owner;
198
+ const sameAccount = owner?.email ? owner.email.toLowerCase() === material.email?.toLowerCase()
199
+ : !material.email && (material.ownershipId !== undefined
200
+ ? owner?.ownershipId === material.ownershipId
201
+ : typeof material.token === "string" && owner?.tokenDigest === digest(material.token));
202
+ if (!owner || owner.projectId !== material.projectId || !sameAccount ||
203
+ (material.baseUrl !== undefined && assertOfficialBaseUrl(material.baseUrl) !== state?.baseUrl)) {
204
+ throw new Error("Antigravity catalog belongs to a different account or endpoint; refresh models before streaming.");
205
+ }
206
+ };
207
+ return {
208
+ getModels: () => structuredClone(state?.catalog.models ?? []),
209
+ filterModels(models, credential) {
210
+ try { return state && accountCatalogKey(credential, state.baseUrl) === state.accountKey ? models : []; }
211
+ catch { return []; }
212
+ },
213
+ assertOwnership,
214
+ resolveRoute(publicId, level, ownership) {
215
+ if (ownership) assertOwnership(ownership);
216
+ const entry = state?.catalog.routes[publicId];
217
+ const route = level === undefined ? entry?.default : entry?.levels[level];
218
+ if (!route) throw new Error("Antigravity model or requested thinking level is not in the current catalog.");
219
+ return { ...route };
220
+ },
221
+ async refreshModels(context) {
222
+ const currentGeneration = ++generation;
223
+ const signal = context.signal;
224
+ signal.throwIfAborted();
225
+ const origin = assertOfficialBaseUrl(context.baseUrl ?? baseUrl);
226
+ const key = accountCatalogKey(context.credential, origin);
227
+ const publish = async (publication: Parameters<RefreshModelsContext["publish"]>[0]): Promise<boolean> => {
228
+ signal.throwIfAborted();
229
+ if (generation !== currentGeneration) return false;
230
+ const update = publication.update;
231
+ return context.publish({ ...publication, update: () => {
232
+ if (generation === currentGeneration && !signal.aborted) update?.();
233
+ } });
234
+ };
235
+ if (state && (state.accountKey !== key || state.baseUrl !== origin)) {
236
+ if (!await publish({ update: () => { state = undefined; } })) return;
237
+ }
238
+ if (!key || context.credential?.type !== "oauth") return;
239
+ const material = parseApiKey(getApiKey(context.credential));
240
+ const owner = { projectId: material.projectId, email: material.email, ownershipId: material.ownershipId, tokenDigest: digest(material.token) };
241
+ // Update ephemeral token binding after native OAuth refresh, including a TTL hit.
242
+ if (state && state.owner.tokenDigest !== owner.tokenDigest) {
243
+ if (!await publish({ update: () => { if (state) state = { ...state, owner }; } })) return;
244
+ }
245
+ // Validate cached source rather than trusting deserialized model objects,
246
+ // request URLs, or routing enums. Ignore pre-upgrade/unowned snapshots.
247
+ const stored = record(context.stored);
248
+ const cache = record(stored?.antigravity);
249
+ if (!state && cache?.schemaVersion === 1 && cache.accountKey === key && cache.baseUrl === origin &&
250
+ typeof stored?.checkedAt === "number" && Number.isFinite(stored.checkedAt) && stored.checkedAt >= 0) {
251
+ let catalog: AntigravityCatalog | undefined;
252
+ try { catalog = buildAntigravityCatalog(cache.source, origin, api); }
253
+ catch { /* Malformed caches are ignored; network discovery can repair them. */ }
254
+ if (catalog) {
255
+ const restored = catalog;
256
+ const checkedAt = stored.checkedAt as number;
257
+ if (!await publish({ update: () => { state = { accountKey: key, baseUrl: origin, checkedAt, catalog: restored, owner }; } })) return;
258
+ }
259
+ }
260
+ if (!context.allowNetwork) return;
261
+ const timestamp = now();
262
+ if (!context.force && state && timestamp >= state.checkedAt && timestamp - state.checkedAt < interval) return;
263
+ const account = await initializeAccount(material.token, { baseUrl: origin, signal, postJson: transport });
264
+ if (account.projectId !== material.projectId) {
265
+ throw new Error("Antigravity initialized a different Cloud Code Assist project; credential re-initialization is required.");
266
+ }
267
+ const payload = await transport(account.baseUrl, "/v1internal:fetchAvailableModels", material.token, { project: account.projectId }, signal);
268
+ signal.throwIfAborted();
269
+ const catalog = buildAntigravityCatalog(payload, account.baseUrl, api);
270
+ const checkedAt = now();
271
+ const persist: AntigravityStoredCatalog = { models: catalog.models, checkedAt,
272
+ antigravity: { schemaVersion: 1, accountKey: key, baseUrl: origin, source: catalog.source } };
273
+ await publish({ persist, update: () => { state = { accountKey: key, baseUrl: origin, checkedAt, catalog, owner }; } });
274
+ },
275
+ };
276
+ }
package/src/client.ts ADDED
@@ -0,0 +1,100 @@
1
+ import { arch, platform } from "node:process";
2
+
3
+ export const DEFAULT_BASE_URL = "https://daily-cloudcode-pa.googleapis.com";
4
+ const OFFICIAL_ORIGINS = new Set([
5
+ DEFAULT_BASE_URL,
6
+ "https://cloudcode-pa.googleapis.com",
7
+ "https://daily-cloudcode-pa.sandbox.googleapis.com",
8
+ ]);
9
+ export const CLIENT_VERSION = "1.5.9";
10
+ export const CLIENT_BUILD = "1021519450";
11
+ export const CLIENT_USER_AGENT = `antigravity/cli/${CLIENT_VERSION} (aidev_client; os_type=${platform === "win32" ? "windows" : platform}; arch=${arch === "x64" ? "amd64" : arch}; cl=${CLIENT_BUILD}; auth_method=consumer)`;
12
+
13
+ /** Only official HTTPS origins can receive subscription credentials. No path overrides. */
14
+ export function assertOfficialBaseUrl(value: string): string {
15
+ const url = new URL(value);
16
+ if (url.username || url.password || url.search || url.hash || url.pathname !== "/" || !OFFICIAL_ORIGINS.has(url.origin)) {
17
+ throw new Error("Antigravity requires an approved official HTTPS origin.");
18
+ }
19
+ return url.origin;
20
+ }
21
+
22
+ export class AntigravityHttpError extends Error {
23
+ readonly status: number;
24
+ readonly details: unknown;
25
+ constructor(status: number, details: unknown) {
26
+ super(`Antigravity HTTP ${status}: ${JSON.stringify(details)}`);
27
+ this.name = "AntigravityHttpError";
28
+ this.status = status;
29
+ this.details = details;
30
+ }
31
+ }
32
+
33
+ export interface JsonRequestOptions {
34
+ fetch?: typeof fetch;
35
+ timeoutMs?: number;
36
+ }
37
+
38
+ /** One bounded request; no endpoint failover, redirects, or internal retries. */
39
+ export async function postJson(
40
+ baseUrl: string,
41
+ path: string,
42
+ token: string,
43
+ payload: unknown,
44
+ signal?: AbortSignal,
45
+ options: JsonRequestOptions = {},
46
+ ): Promise<unknown> {
47
+ const origin = assertOfficialBaseUrl(baseUrl);
48
+ if (!/^\/v1internal:[A-Za-z]+$/.test(path)) throw new Error("Invalid Antigravity operation path.");
49
+ signal?.throwIfAborted();
50
+ const timeout = AbortSignal.timeout(options.timeoutMs ?? 30_000);
51
+ const requestSignal = signal ? AbortSignal.any([signal, timeout]) : timeout;
52
+ const response = await (options.fetch ?? fetch)(`${origin}${path}`, {
53
+ method: "POST",
54
+ redirect: "error",
55
+ signal: requestSignal,
56
+ headers: {
57
+ Authorization: `Bearer ${token}`,
58
+ "Content-Type": "application/json",
59
+ "User-Agent": CLIENT_USER_AGENT,
60
+ },
61
+ body: JSON.stringify(payload),
62
+ });
63
+ const body = await readBoundedBody(response, 4 * 1024 * 1024, requestSignal);
64
+ let parsed: unknown;
65
+ try {
66
+ parsed = JSON.parse(body);
67
+ } catch {
68
+ if (response.ok) throw new Error("Antigravity returned invalid JSON.");
69
+ parsed = body;
70
+ }
71
+ if (!response.ok) throw new AntigravityHttpError(response.status, parsed);
72
+ requestSignal.throwIfAborted();
73
+ return parsed;
74
+ }
75
+
76
+ export async function readBoundedBody(response: Response, limit: number, signal?: AbortSignal): Promise<string> {
77
+ if (!response.body) return "";
78
+ const reader = response.body.getReader();
79
+ const chunks: Uint8Array[] = [];
80
+ let bytes = 0;
81
+ const abort = () => { void reader.cancel(signal?.reason).catch(() => {}); };
82
+ signal?.addEventListener("abort", abort, { once: true });
83
+ try {
84
+ signal?.throwIfAborted();
85
+ while (true) {
86
+ const chunk = await reader.read();
87
+ signal?.throwIfAborted();
88
+ if (chunk.done) break;
89
+ bytes += chunk.value.byteLength;
90
+ if (bytes > limit) throw new Error("Antigravity response exceeds the size limit.");
91
+ chunks.push(chunk.value);
92
+ }
93
+ return new TextDecoder("utf-8", { fatal: true }).decode(Buffer.concat(chunks));
94
+ } finally {
95
+ signal?.removeEventListener("abort", abort);
96
+ // A broken injected source must not hold cancellation or timeout open.
97
+ void reader.cancel().catch(() => {});
98
+ reader.releaseLock();
99
+ }
100
+ }
@@ -0,0 +1,28 @@
1
+ import { readFileSync } from "node:fs";
2
+ import { findPackageJSON } from "node:module";
3
+ import { dirname, join, resolve } from "node:path";
4
+ import { pathToFileURL } from "node:url";
5
+ import { getPackageDir } from "@earendil-works/pi-coding-agent";
6
+ import type * as GoogleShared from "@earendil-works/pi-ai/api/google-shared";
7
+
8
+ /**
9
+ * Pi 1.5.2 aliases the pi-ai root, but not its api/* subpaths, for extensions.
10
+ * Resolve the host's public export using public Node/Pi APIs instead of relying
11
+ * on this extension's devDependencies or bundling a second Pi implementation.
12
+ */
13
+ const anchor = join(getPackageDir(), "package.json");
14
+ const manifestPath = findPackageJSON("@earendil-works/pi-ai", pathToFileURL(anchor));
15
+ if (!manifestPath) throw new Error("The Pi host does not provide @earendil-works/pi-ai.");
16
+ const manifest = JSON.parse(readFileSync(manifestPath, "utf8")) as {
17
+ exports?: Record<string, string | { import?: string }>;
18
+ };
19
+ const entry = manifest.exports?.["./api/google-shared"] ?? manifest.exports?.["./api/*"];
20
+ const target = typeof entry === "string" ? entry : entry?.import;
21
+ if (typeof target !== "string" || !target.startsWith("./")) {
22
+ throw new Error("The Pi host does not expose the public Google converter API.");
23
+ }
24
+ const googleShared = await import(
25
+ pathToFileURL(resolve(dirname(manifestPath), target.replace("*", "google-shared"))).href
26
+ ) as typeof GoogleShared;
27
+
28
+ export const { convertMessages, convertTools, mapToolChoice } = googleShared;
package/src/index.ts ADDED
@@ -0,0 +1,72 @@
1
+ import type { Api, Model, Provider, ProviderAuth, StreamOptions, TranscriptContext } from "@earendil-works/pi-ai";
2
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
3
+ import { createAntigravityAuth, parseApiKey } from "./auth.ts";
4
+ import { createAntigravityCatalog } from "./catalog.ts";
5
+ import { assertOfficialBaseUrl, DEFAULT_BASE_URL, postJson } from "./client.ts";
6
+ import { buildRequest, createRequestIdentity, createSessionIdentity } from "./request.ts";
7
+ import { streamAntigravity, type AntigravityStreamOptions } from "./stream.ts";
8
+
9
+ export const PROVIDER_ID = "antigravity";
10
+ export const API_ID = "antigravity";
11
+
12
+ export interface AntigravityProviderOptions {
13
+ baseUrl?: string;
14
+ fetch?: typeof globalThis.fetch;
15
+ /** Injection for isolated integration tests; production uses native OAuth. */
16
+ auth?: ProviderAuth;
17
+ now?: () => number;
18
+ refreshIntervalMs?: number;
19
+ }
20
+
21
+ /** No global registry, CLI storage, startup I/O, tools, polling, or background resources. */
22
+ export function antigravityProvider(options: AntigravityProviderOptions = {}): Provider {
23
+ const baseUrl = assertOfficialBaseUrl(options.baseUrl ?? DEFAULT_BASE_URL);
24
+ const catalog = createAntigravityCatalog({
25
+ api: API_ID,
26
+ baseUrl,
27
+ now: options.now,
28
+ refreshIntervalMs: options.refreshIntervalMs,
29
+ postJson: (origin, path, token, payload, signal) => postJson(origin, path, token, payload, signal, { fetch: options.fetch }),
30
+ });
31
+ const stream = (model: Model<Api>, context: TranscriptContext, requestOptions?: StreamOptions) => streamAntigravity(model, context, requestOptions, {
32
+ fetch: options.fetch,
33
+ resolveCredentials(requestModel, streamOptions) {
34
+ const credential = parseApiKey(streamOptions.apiKey);
35
+ return { token: credential.token, baseUrl: assertOfficialBaseUrl(requestModel.baseUrl || baseUrl) };
36
+ },
37
+ buildPayload(requestModel, transcript, streamOptions: AntigravityStreamOptions) {
38
+ const credential = parseApiKey(streamOptions.apiKey);
39
+ const route = catalog.resolveRoute(requestModel.id, streamOptions.reasoning, { ...credential, baseUrl: requestModel.baseUrl || baseUrl });
40
+ // Pi supplies a different session ID for forks. A missing ID means an
41
+ // ephemeral direct call; never infer shared session identity from prompt text.
42
+ const sessionKey = streamOptions.sessionId === undefined ? undefined
43
+ : JSON.stringify([credential.email?.toLowerCase() ?? credential.ownershipId ?? credential.token, credential.projectId, streamOptions.sessionId]);
44
+ const session = createSessionIdentity(sessionKey);
45
+ // Local transcript bookkeeping, not a reconstruction of unobserved CLI
46
+ // retry counters. Per-attempt UUIDs keep concurrent requests distinct.
47
+ const requestIndex = transcript.messages.filter(message => message.role === "assistant").length;
48
+ const identity = createRequestIdentity(session, credential.projectId, { requestIndex, timestamp: options.now?.() });
49
+ return buildRequest(requestModel, transcript, streamOptions, route, identity);
50
+ },
51
+ });
52
+ return {
53
+ id: PROVIDER_ID,
54
+ name: "Antigravity",
55
+ baseUrl,
56
+ auth: options.auth ?? createAntigravityAuth({
57
+ baseUrl,
58
+ fetch: options.fetch,
59
+ now: options.now,
60
+ postJson: (origin, path, token, payload, signal) => postJson(origin, path, token, payload, signal, { fetch: options.fetch }),
61
+ }),
62
+ getModels: catalog.getModels,
63
+ filterModels: catalog.filterModels,
64
+ refreshModels: catalog.refreshModels,
65
+ stream,
66
+ streamSimple: stream,
67
+ };
68
+ }
69
+
70
+ export default function antigravity(pi: ExtensionAPI): void {
71
+ pi.registerProvider(antigravityProvider());
72
+ }