@specode/pi-antigravity 0.0.0-stage → 0.1.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.
- package/LICENSE +21 -0
- package/README.md +84 -2
- package/README.zh-CN.md +85 -0
- package/THIRD_PARTY_NOTICES.md +22 -0
- package/package.json +46 -4
- package/src/auth.ts +288 -0
- package/src/catalog.ts +276 -0
- package/src/client.ts +100 -0
- package/src/index.ts +72 -0
- package/src/request.ts +292 -0
- package/src/stream.ts +387 -0
package/src/request.ts
ADDED
|
@@ -0,0 +1,292 @@
|
|
|
1
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
2
|
+
import type { Api, Model, SimpleStreamOptions, TranscriptContext } from "@earendil-works/pi-ai";
|
|
3
|
+
import { convertMessages, convertTools, mapToolChoice } from "@earendil-works/pi-ai/api/google-shared";
|
|
4
|
+
import { getCurrentSystemPrompt, getCurrentTools } from "@earendil-works/pi-ai/utils/transcript";
|
|
5
|
+
|
|
6
|
+
const PI_OPENING = "You are a coding assistant running inside pi, a coding agent harness. Help users inspect files, run commands, and modify code.";
|
|
7
|
+
const CLI_OPENING = "You are a coding agent helping the user with their project.";
|
|
8
|
+
const THINKING_LEVELS = new Set(["MINIMAL", "LOW", "MEDIUM", "HIGH"]);
|
|
9
|
+
|
|
10
|
+
/** Accept explicit off at this boundary; Pi's simple options normally represent it as undefined. */
|
|
11
|
+
export type RequestOptions = Omit<SimpleStreamOptions, "reasoning"> & {
|
|
12
|
+
reasoning?: SimpleStreamOptions["reasoning"] | "off";
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
/** Catalog-selected route, already resolved for the requested thinking level. */
|
|
16
|
+
export interface RequestRoute {
|
|
17
|
+
runtimeId: string;
|
|
18
|
+
modelEnum: string;
|
|
19
|
+
thinkingLevel?: "MINIMAL" | "LOW" | "MEDIUM" | "HIGH";
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export interface SessionIdentity {
|
|
23
|
+
readonly cascadeId: string;
|
|
24
|
+
readonly rootCascadeId: string;
|
|
25
|
+
readonly trajectoryId: string;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export interface RequestIdentity extends SessionIdentity {
|
|
29
|
+
readonly projectId: string;
|
|
30
|
+
readonly requestUuid: string;
|
|
31
|
+
readonly timestamp: number;
|
|
32
|
+
readonly requestIndex: number;
|
|
33
|
+
/** Override after compaction/resume if the caller has a persisted logical step. */
|
|
34
|
+
readonly step?: number;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export interface RequestIdentityOptions {
|
|
38
|
+
requestIndex?: number;
|
|
39
|
+
step?: number;
|
|
40
|
+
timestamp?: number;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export interface CliRequest {
|
|
44
|
+
project: string;
|
|
45
|
+
model: string;
|
|
46
|
+
requestId: string;
|
|
47
|
+
requestType: "agent";
|
|
48
|
+
userAgent: "antigravity";
|
|
49
|
+
request: {
|
|
50
|
+
contents: ReturnType<typeof convertMessages>;
|
|
51
|
+
systemInstruction: { role: "user"; parts: { text: string }[] };
|
|
52
|
+
tools: NonNullable<ReturnType<typeof convertTools>>;
|
|
53
|
+
labels: {
|
|
54
|
+
cascade_id: string;
|
|
55
|
+
root_cascade_id: string;
|
|
56
|
+
trajectory_id: string;
|
|
57
|
+
request_id: string;
|
|
58
|
+
last_step_index: string;
|
|
59
|
+
model_enum: string;
|
|
60
|
+
used_claude: string;
|
|
61
|
+
used_claude_conservative: string;
|
|
62
|
+
used_non_gemini_model: string;
|
|
63
|
+
};
|
|
64
|
+
generationConfig: {
|
|
65
|
+
maxOutputTokens: number;
|
|
66
|
+
temperature?: number;
|
|
67
|
+
thinkingConfig?: { includeThoughts: true; thinkingLevel: NonNullable<RequestRoute["thinkingLevel"]> };
|
|
68
|
+
};
|
|
69
|
+
sessionId: string;
|
|
70
|
+
toolConfig?: { functionCallingConfig: { mode: ReturnType<typeof mapToolChoice> } };
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** Rewrite only the exact known leading sentences, never text elsewhere in the prompt. */
|
|
75
|
+
export function rewriteDefaultPrompt(prompt: string): string {
|
|
76
|
+
if (!prompt.startsWith(PI_OPENING)) return prompt;
|
|
77
|
+
const suffix = prompt.slice(PI_OPENING.length);
|
|
78
|
+
if (suffix !== "" && !/^\s/.test(suffix)) return prompt;
|
|
79
|
+
return CLI_OPENING + suffix;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** Official hashToSessionID: UTF-8 bytes, unsigned FNV-1a arithmetic, signed decimal output. */
|
|
83
|
+
export function fnvSessionId(seed: string | Uint8Array = ""): string {
|
|
84
|
+
let hash = 0xcbf29ce484222325n;
|
|
85
|
+
const bytes = typeof seed === "string" ? new TextEncoder().encode(seed) : seed;
|
|
86
|
+
for (const byte of bytes) hash = BigInt.asUintN(64, (hash ^ BigInt(byte)) * 0x100000001b3n);
|
|
87
|
+
return BigInt.asIntN(64, hash).toString();
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function sessionUuid(scope: string, key: string): string {
|
|
91
|
+
const bytes = createHash("sha256").update("pi-antigravity/identity/v1\0").update(scope).update("\0").update(key).digest();
|
|
92
|
+
// RFC 9562 UUIDv8: provider-local deterministic identity, not an inferred CLI algorithm.
|
|
93
|
+
bytes[6] = (bytes[6]! & 0x0f) | 0x80;
|
|
94
|
+
bytes[8] = (bytes[8]! & 0x3f) | 0x80;
|
|
95
|
+
const hex = bytes.subarray(0, 16).toString("hex");
|
|
96
|
+
return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* No process-global state. Pass a stable, account/session/branch-scoped key to resume;
|
|
101
|
+
* use a different key for a fork. Omit it for an ephemeral conversation.
|
|
102
|
+
* These UUIDs are independent of the CLI's FNV sessionId (whose observed seed is empty).
|
|
103
|
+
*/
|
|
104
|
+
export function createSessionIdentity(sessionKey?: string): SessionIdentity {
|
|
105
|
+
if (sessionKey !== undefined && sessionKey.length === 0) throw new Error("Session key must not be empty");
|
|
106
|
+
const cascadeId = sessionKey === undefined ? randomUUID() : sessionUuid("cascade", sessionKey);
|
|
107
|
+
const trajectoryId = sessionKey === undefined ? randomUUID() : sessionUuid("trajectory", sessionKey);
|
|
108
|
+
return Object.freeze({ cascadeId, rootCascadeId: cascadeId, trajectoryId });
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Create once per logical HTTP attempt; reuse the returned value for exact payload replay.
|
|
113
|
+
* Fresh UUIDs make parallel attempts unique even with identical timestamp/index/step.
|
|
114
|
+
* requestIndex is caller-owned bookkeeping, not a claimed reconstruction of CLI counters.
|
|
115
|
+
*/
|
|
116
|
+
export function createRequestIdentity(
|
|
117
|
+
session: SessionIdentity,
|
|
118
|
+
projectId: string,
|
|
119
|
+
options: RequestIdentityOptions = {},
|
|
120
|
+
): RequestIdentity {
|
|
121
|
+
const identity: RequestIdentity = {
|
|
122
|
+
...session,
|
|
123
|
+
projectId,
|
|
124
|
+
requestUuid: randomUUID(),
|
|
125
|
+
timestamp: options.timestamp ?? Date.now(),
|
|
126
|
+
requestIndex: options.requestIndex ?? 0,
|
|
127
|
+
...(options.step === undefined ? {} : { step: options.step }),
|
|
128
|
+
};
|
|
129
|
+
validateIdentity(identity);
|
|
130
|
+
return Object.freeze(identity);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function nonnegativeInteger(value: number, name: string): void {
|
|
134
|
+
if (!Number.isSafeInteger(value) || value < 0) throw new Error(`${name} must be a nonnegative safe integer`);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function validateIdentity(identity: RequestIdentity): void {
|
|
138
|
+
const uuid = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
139
|
+
for (const key of ["cascadeId", "rootCascadeId", "trajectoryId", "requestUuid"] as const) {
|
|
140
|
+
if (!uuid.test(identity[key])) throw new Error(`${key} must be a UUID`);
|
|
141
|
+
}
|
|
142
|
+
if (!identity.projectId.trim()) throw new Error("Project ID is required");
|
|
143
|
+
nonnegativeInteger(identity.timestamp, "timestamp");
|
|
144
|
+
nonnegativeInteger(identity.requestIndex, "requestIndex");
|
|
145
|
+
if (identity.step !== undefined) nonnegativeInteger(identity.step, "step");
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
const SCHEMA_MAPS = new Set(["properties", "patternProperties", "$defs", "definitions", "dependentSchemas", "dependencies"]);
|
|
149
|
+
const SUBSCHEMAS = new Set([
|
|
150
|
+
"additionalProperties", "unevaluatedProperties", "propertyNames", "items", "prefixItems",
|
|
151
|
+
"additionalItems", "unevaluatedItems", "contains", "allOf", "anyOf", "oneOf", "not",
|
|
152
|
+
"if", "then", "else", "contentSchema",
|
|
153
|
+
]);
|
|
154
|
+
const SCHEMA_TYPES = new Set(["string", "number", "integer", "boolean", "array", "object", "null"]);
|
|
155
|
+
const SCHEMA_META = new Set(["$schema", "$id", "$anchor", "$dynamicAnchor", "$vocabulary", "$comment", "$defs", "definitions"]);
|
|
156
|
+
|
|
157
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
158
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* Visit schema positions, not arbitrary JSON data. Names (including "type" and
|
|
163
|
+
* "__proto__"), enum/const/default/examples, descriptions and extensions stay intact.
|
|
164
|
+
* No dereferencing or union flattening: unsupported server schemas must fail visibly.
|
|
165
|
+
*/
|
|
166
|
+
export function uppercaseSchemaTypes(schema: unknown): unknown {
|
|
167
|
+
return transformSchema(schema, false);
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
function transformSchema(schema: unknown, legacyParameters: boolean): unknown {
|
|
171
|
+
if (Array.isArray(schema)) return schema.map((child) => transformSchema(child, legacyParameters));
|
|
172
|
+
if (!isRecord(schema)) return schema;
|
|
173
|
+
if (legacyParameters && (Object.hasOwn(schema, "$ref") || Object.hasOwn(schema, "$dynamicRef"))) {
|
|
174
|
+
throw new Error("CLI parameters do not support schema references; tool declarations must provide an inline schema");
|
|
175
|
+
}
|
|
176
|
+
return Object.fromEntries(Object.entries(schema).filter(([key]) => !legacyParameters || !SCHEMA_META.has(key)).map(([key, value]) => {
|
|
177
|
+
if (key === "type") {
|
|
178
|
+
const upper = (type: unknown) => typeof type === "string" && SCHEMA_TYPES.has(type) ? type.toUpperCase() : type;
|
|
179
|
+
return [key, Array.isArray(value) ? value.map(upper) : upper(value)];
|
|
180
|
+
}
|
|
181
|
+
if (SCHEMA_MAPS.has(key) && isRecord(value)) {
|
|
182
|
+
return [key, Object.fromEntries(Object.entries(value).map(([name, child]) => [name, transformSchema(child, legacyParameters)]))];
|
|
183
|
+
}
|
|
184
|
+
return [key, SUBSCHEMAS.has(key) ? transformSchema(value, legacyParameters) : structuredClone(value)];
|
|
185
|
+
}));
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
function validateThinking(model: Model<Api>, options: RequestOptions | undefined, route: RequestRoute): void {
|
|
189
|
+
if (!route.runtimeId.trim() || !route.modelEnum.trim()) throw new Error("A catalog runtime ID and model enum are required");
|
|
190
|
+
if (options?.thinkingBudgets && Object.values(options.thinkingBudgets).some((value) => value !== undefined)) {
|
|
191
|
+
throw new Error("CLI routes use thinking tiers, not token budgets");
|
|
192
|
+
}
|
|
193
|
+
if (!model.reasoning) {
|
|
194
|
+
if (route.thinkingLevel !== undefined) throw new Error("Nonthinking models must not supply a thinking tier");
|
|
195
|
+
if (options?.reasoning !== undefined && options.reasoning !== "off") throw new Error("Nonthinking models only accept reasoning off");
|
|
196
|
+
return;
|
|
197
|
+
}
|
|
198
|
+
if (route.thinkingLevel === undefined || !THINKING_LEVELS.has(route.thinkingLevel)) throw new Error("Unsupported or missing CLI thinking tier");
|
|
199
|
+
if (options?.reasoning === "off") throw new Error("Requested thinking level off does not match a thinking route");
|
|
200
|
+
if (options?.reasoning !== undefined) {
|
|
201
|
+
const mapped = model.thinkingLevelMap?.[options.reasoning];
|
|
202
|
+
const requested = mapped === undefined ? options.reasoning : mapped;
|
|
203
|
+
if (typeof requested !== "string" || (requested !== route.runtimeId && requested.toUpperCase() !== route.thinkingLevel)) {
|
|
204
|
+
throw new Error(`Requested thinking level ${options.reasoning} does not match catalog route ${route.runtimeId}/${route.thinkingLevel}`);
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/**
|
|
210
|
+
* Pure serialization. Transport owns onPayload (including replacement), response/stream
|
|
211
|
+
* hooks, authentication headers, endpoint validation, cancellation and HTTP execution.
|
|
212
|
+
* No fallback model, thinking clamp, endpoint retry, or hidden execution ID is created.
|
|
213
|
+
*/
|
|
214
|
+
export function buildRequest(
|
|
215
|
+
model: Model<Api>,
|
|
216
|
+
context: TranscriptContext,
|
|
217
|
+
options: RequestOptions | undefined,
|
|
218
|
+
route: RequestRoute,
|
|
219
|
+
identity: RequestIdentity,
|
|
220
|
+
): CliRequest {
|
|
221
|
+
validateIdentity(identity);
|
|
222
|
+
validateThinking(model, options, route);
|
|
223
|
+
const maxOutputTokens = options?.maxTokens ?? model.maxTokens;
|
|
224
|
+
if (!Number.isSafeInteger(maxOutputTokens) || maxOutputTokens <= 0 || maxOutputTokens > model.maxTokens) {
|
|
225
|
+
throw new Error("maxTokens must be a positive integer within the model output limit");
|
|
226
|
+
}
|
|
227
|
+
if (options?.temperature !== undefined && (!Number.isFinite(options.temperature) || options.temperature < 0)) {
|
|
228
|
+
throw new Error("temperature must be a finite nonnegative number");
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
// The public converter's generic accepts Google API names, but its implementation
|
|
232
|
+
// supports this wire format with custom providers. This is a type-only narrowing:
|
|
233
|
+
// changing model.id or model.api here would invalidate same-model thought signatures.
|
|
234
|
+
const contents = structuredClone(convertMessages(model as Model<"google-generative-ai">, context));
|
|
235
|
+
// Only this exact runtime route has a verified function-response role difference.
|
|
236
|
+
if (route.runtimeId === "gemini-3.8-flash-low") {
|
|
237
|
+
for (const content of contents) {
|
|
238
|
+
if (content.parts?.length && content.parts.every((part) => part.functionResponse !== undefined)) content.role = "model";
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
const currentTools = getCurrentTools(context.messages);
|
|
243
|
+
const convertedTools = convertTools(currentTools, true, false) ?? [];
|
|
244
|
+
const declarations = convertedTools.flatMap((tool) => tool.functionDeclarations);
|
|
245
|
+
const tools = declarations.map((declaration, index) => ({
|
|
246
|
+
functionDeclarations: [{
|
|
247
|
+
...declaration,
|
|
248
|
+
// Pi 1.5.2's legacy OpenAPI sanitizer walks user name maps as keywords
|
|
249
|
+
// (and skips arrays). Restore the original schema with a position-aware
|
|
250
|
+
// traversal so named properties and literal values are not silently lost.
|
|
251
|
+
// Strip the same meta declarations, but reject references before dropping
|
|
252
|
+
// their definitions rather than sending a silently broken schema.
|
|
253
|
+
parameters: transformSchema(currentTools[index]!.parameters, true),
|
|
254
|
+
}],
|
|
255
|
+
}));
|
|
256
|
+
const step = identity.step ?? Math.max(0, contents.length - 1);
|
|
257
|
+
const usedClaude = route.runtimeId.startsWith("claude-");
|
|
258
|
+
const payload: CliRequest = {
|
|
259
|
+
project: identity.projectId,
|
|
260
|
+
model: route.runtimeId,
|
|
261
|
+
requestId: `agent/${identity.cascadeId}/${identity.timestamp}/${identity.requestUuid}/${step}`,
|
|
262
|
+
requestType: "agent",
|
|
263
|
+
userAgent: "antigravity",
|
|
264
|
+
request: {
|
|
265
|
+
contents,
|
|
266
|
+
systemInstruction: { role: "user", parts: [{ text: rewriteDefaultPrompt(getCurrentSystemPrompt(context.messages)) }] },
|
|
267
|
+
tools,
|
|
268
|
+
labels: {
|
|
269
|
+
cascade_id: identity.cascadeId,
|
|
270
|
+
root_cascade_id: identity.rootCascadeId,
|
|
271
|
+
trajectory_id: identity.trajectoryId,
|
|
272
|
+
request_id: `${identity.requestUuid}-${identity.requestIndex}`,
|
|
273
|
+
last_step_index: String(step),
|
|
274
|
+
model_enum: route.modelEnum,
|
|
275
|
+
used_claude: String(usedClaude),
|
|
276
|
+
used_claude_conservative: String(usedClaude),
|
|
277
|
+
used_non_gemini_model: String(!route.runtimeId.startsWith("gemini-")),
|
|
278
|
+
},
|
|
279
|
+
generationConfig: {
|
|
280
|
+
maxOutputTokens,
|
|
281
|
+
...(options?.temperature === undefined ? {} : { temperature: options.temperature }),
|
|
282
|
+
...(route.thinkingLevel === undefined ? {} : { thinkingConfig: { includeThoughts: true as const, thinkingLevel: route.thinkingLevel } }),
|
|
283
|
+
},
|
|
284
|
+
// The normal CLI path uses an empty seed; Pi's session key is NOT this seed.
|
|
285
|
+
sessionId: fnvSessionId(),
|
|
286
|
+
...(options?.toolChoice === undefined ? {} : {
|
|
287
|
+
toolConfig: { functionCallingConfig: { mode: mapToolChoice(options.toolChoice) } },
|
|
288
|
+
}),
|
|
289
|
+
},
|
|
290
|
+
};
|
|
291
|
+
return payload;
|
|
292
|
+
}
|
package/src/stream.ts
ADDED
|
@@ -0,0 +1,387 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import type {
|
|
3
|
+
Api, AssistantMessage, AssistantMessageEventStream, JsonObject, Model, SimpleStreamOptions, TranscriptContext,
|
|
4
|
+
} from "@earendil-works/pi-ai";
|
|
5
|
+
import { calculateCost } from "@earendil-works/pi-ai/models";
|
|
6
|
+
import { createAssistantMessageEventStream } from "@earendil-works/pi-ai/utils/event-stream";
|
|
7
|
+
import { CLIENT_USER_AGENT, DEFAULT_BASE_URL, assertOfficialBaseUrl } from "./client.ts";
|
|
8
|
+
|
|
9
|
+
export interface AntigravityStreamOptions extends SimpleStreamOptions {
|
|
10
|
+
/** Response-header deadline, excluding payload building and hooks. Default: 30 seconds. */
|
|
11
|
+
headTimeoutMs?: number;
|
|
12
|
+
/** Deadline for each body read, excluding time spent in observer hooks. Default: 120 seconds. */
|
|
13
|
+
idleTimeoutMs?: number;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export interface AntigravityStreamCredentials {
|
|
17
|
+
token: string;
|
|
18
|
+
baseUrl?: string;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export interface AntigravityStreamDependencies {
|
|
22
|
+
buildPayload(model: Model<Api>, context: TranscriptContext, options: AntigravityStreamOptions): unknown | Promise<unknown>;
|
|
23
|
+
resolveCredentials(model: Model<Api>, options: AntigravityStreamOptions): AntigravityStreamCredentials | Promise<AntigravityStreamCredentials>;
|
|
24
|
+
fetch?: typeof globalThis.fetch;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** Evidence is provider data, not a guessed quota/retry classification. Never logged here. */
|
|
28
|
+
export class AntigravityStreamError extends Error {
|
|
29
|
+
constructor(message: string, readonly evidence: JsonObject = {}) {
|
|
30
|
+
super(message);
|
|
31
|
+
this.name = "AntigravityStreamError";
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const MAX_FRAME_CHARS = 4 * 1024 * 1024;
|
|
36
|
+
const MAX_ERROR_BYTES = 64 * 1024;
|
|
37
|
+
|
|
38
|
+
/** Incremental SSE framing. A partial data frame at EOF is an error, not a successful response. */
|
|
39
|
+
class SseDecoder {
|
|
40
|
+
private decoder = new TextDecoder("utf-8", { fatal: true });
|
|
41
|
+
private line = "";
|
|
42
|
+
private data: string[] = [];
|
|
43
|
+
private frameChars = 0;
|
|
44
|
+
private skipLF = false;
|
|
45
|
+
|
|
46
|
+
push(bytes: Uint8Array): string[] {
|
|
47
|
+
return this.consume(this.decoder.decode(bytes, { stream: true }));
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
finish(): string[] {
|
|
51
|
+
const frames = this.consume(this.decoder.decode());
|
|
52
|
+
if (this.data.length || (this.line && !this.line.startsWith(":"))) {
|
|
53
|
+
throw new AntigravityStreamError("Antigravity SSE ended with an incomplete frame");
|
|
54
|
+
}
|
|
55
|
+
return frames;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
private consume(text: string): string[] {
|
|
59
|
+
const frames: string[] = [];
|
|
60
|
+
for (const char of text) {
|
|
61
|
+
if (this.skipLF) {
|
|
62
|
+
this.skipLF = false;
|
|
63
|
+
if (char === "\n") continue;
|
|
64
|
+
}
|
|
65
|
+
if (++this.frameChars > MAX_FRAME_CHARS) throw new AntigravityStreamError("Antigravity SSE frame exceeds the size limit");
|
|
66
|
+
if (char === "\r" || char === "\n") {
|
|
67
|
+
if (char === "\r") this.skipLF = true;
|
|
68
|
+
if (this.line === "") {
|
|
69
|
+
if (this.data.length) frames.push(this.data.join("\n"));
|
|
70
|
+
this.data = [];
|
|
71
|
+
this.frameChars = 0;
|
|
72
|
+
} else if (this.line === "data" || this.line.startsWith("data:")) {
|
|
73
|
+
let value = this.line.slice(5);
|
|
74
|
+
if (value.startsWith(" ")) value = value.slice(1);
|
|
75
|
+
this.data.push(value);
|
|
76
|
+
}
|
|
77
|
+
this.line = "";
|
|
78
|
+
} else {
|
|
79
|
+
this.line += char;
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
return frames;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function object(value: unknown, label: string): Record<string, unknown> {
|
|
87
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) {
|
|
88
|
+
throw new AntigravityStreamError(`Invalid Antigravity ${label}: expected an object`);
|
|
89
|
+
}
|
|
90
|
+
return value as Record<string, unknown>;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function optionalString(value: unknown, label: string): string | undefined {
|
|
94
|
+
if (value === undefined) return undefined;
|
|
95
|
+
if (typeof value !== "string") throw new AntigravityStreamError(`Invalid Antigravity ${label}: expected a string`);
|
|
96
|
+
return value;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function deadline(value: number | undefined, fallback: number): number {
|
|
100
|
+
const ms = value ?? fallback;
|
|
101
|
+
if (!Number.isFinite(ms) || ms <= 0 || ms > 2 ** 31 - 1) {
|
|
102
|
+
throw new AntigravityStreamError("Antigravity timeouts must be positive finite milliseconds within the timer range");
|
|
103
|
+
}
|
|
104
|
+
return ms;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** Race even non-cooperative injected I/O/hooks against cancellation; always remove listeners/timers. */
|
|
108
|
+
async function waitFor<T>(work: PromiseLike<T> | T, controller: AbortController, timeoutMs?: number, phase?: string): Promise<T> {
|
|
109
|
+
const signal = controller.signal;
|
|
110
|
+
const promise = Promise.resolve(work);
|
|
111
|
+
if (signal.aborted) {
|
|
112
|
+
void promise.catch(() => {});
|
|
113
|
+
signal.throwIfAborted();
|
|
114
|
+
}
|
|
115
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
116
|
+
let abort: () => void = () => {};
|
|
117
|
+
try {
|
|
118
|
+
return await Promise.race([
|
|
119
|
+
promise,
|
|
120
|
+
new Promise<never>((_, reject) => {
|
|
121
|
+
abort = () => reject(signal.reason);
|
|
122
|
+
signal.addEventListener("abort", abort, { once: true });
|
|
123
|
+
if (timeoutMs !== undefined) {
|
|
124
|
+
timer = setTimeout(() => controller.abort(new AntigravityStreamError(
|
|
125
|
+
`Antigravity ${phase} timeout after ${timeoutMs}ms`, { phase: phase ?? "unknown", timeoutMs },
|
|
126
|
+
)), timeoutMs);
|
|
127
|
+
}
|
|
128
|
+
}),
|
|
129
|
+
]);
|
|
130
|
+
} finally {
|
|
131
|
+
signal.removeEventListener("abort", abort);
|
|
132
|
+
clearTimeout(timer);
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/** One logical request, one fetch, no internal retry or endpoint failover. */
|
|
137
|
+
export function streamAntigravity(
|
|
138
|
+
model: Model<Api>,
|
|
139
|
+
context: TranscriptContext,
|
|
140
|
+
options: AntigravityStreamOptions | undefined,
|
|
141
|
+
dependencies: AntigravityStreamDependencies,
|
|
142
|
+
): AssistantMessageEventStream {
|
|
143
|
+
const stream = createAssistantMessageEventStream();
|
|
144
|
+
const output: AssistantMessage = {
|
|
145
|
+
role: "assistant", content: [], api: model.api, provider: model.provider, model: model.id,
|
|
146
|
+
timestamp: Date.now(), stopReason: "pending",
|
|
147
|
+
usage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, totalTokens: 0,
|
|
148
|
+
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 } },
|
|
149
|
+
};
|
|
150
|
+
const controller = new AbortController();
|
|
151
|
+
const abort = () => controller.abort(options?.signal?.reason ?? new Error("Request aborted"));
|
|
152
|
+
options?.signal?.addEventListener("abort", abort, { once: true });
|
|
153
|
+
if (options?.signal?.aborted) abort();
|
|
154
|
+
const effectiveOptions: AntigravityStreamOptions = { ...options, signal: controller.signal };
|
|
155
|
+
|
|
156
|
+
void (async () => {
|
|
157
|
+
let reader: ReadableStreamDefaultReader<Uint8Array> | undefined;
|
|
158
|
+
let currentIndex: number | undefined;
|
|
159
|
+
let finishReason: string | undefined;
|
|
160
|
+
const usage: Record<string, number> = {};
|
|
161
|
+
const closeBlock = () => {
|
|
162
|
+
if (currentIndex === undefined) return;
|
|
163
|
+
const block = output.content[currentIndex]!;
|
|
164
|
+
if (block.type === "text") stream.push({ type: "text_end", contentIndex: currentIndex, content: block.text, partial: output });
|
|
165
|
+
if (block.type === "thinking") stream.push({ type: "thinking_end", contentIndex: currentIndex, content: block.thinking, partial: output });
|
|
166
|
+
currentIndex = undefined;
|
|
167
|
+
};
|
|
168
|
+
const readUsage = (value: unknown) => {
|
|
169
|
+
for (const [key, count] of Object.entries(object(value, "usageMetadata"))) {
|
|
170
|
+
if (!["promptTokenCount", "candidatesTokenCount", "cachedContentTokenCount", "thoughtsTokenCount", "totalTokenCount"].includes(key)) continue;
|
|
171
|
+
if (typeof count !== "number" || !Number.isSafeInteger(count) || count < 0) {
|
|
172
|
+
throw new AntigravityStreamError(`Invalid Antigravity usage counter: ${key}`);
|
|
173
|
+
}
|
|
174
|
+
usage[key] = count;
|
|
175
|
+
}
|
|
176
|
+
const cached = usage.cachedContentTokenCount ?? 0;
|
|
177
|
+
const prompt = usage.promptTokenCount ?? cached;
|
|
178
|
+
if (cached > prompt) throw new AntigravityStreamError("Antigravity cached tokens exceed prompt tokens");
|
|
179
|
+
output.usage.input = prompt - cached;
|
|
180
|
+
output.usage.cacheRead = cached;
|
|
181
|
+
output.usage.output = (usage.candidatesTokenCount ?? 0) + (usage.thoughtsTokenCount ?? 0);
|
|
182
|
+
if (usage.thoughtsTokenCount !== undefined) output.usage.reasoning = usage.thoughtsTokenCount;
|
|
183
|
+
output.usage.totalTokens = usage.totalTokenCount ?? prompt + output.usage.output;
|
|
184
|
+
calculateCost(model, output.usage);
|
|
185
|
+
};
|
|
186
|
+
const readPart = (value: unknown, explicitBoundary: boolean) => {
|
|
187
|
+
const part = object(value, "part");
|
|
188
|
+
const signature = optionalString(part.thoughtSignature, "thoughtSignature");
|
|
189
|
+
if (part.functionCall !== undefined) {
|
|
190
|
+
if (finishReason) throw new AntigravityStreamError("Antigravity sent a tool call after the finish reason");
|
|
191
|
+
if (part.text !== undefined) throw new AntigravityStreamError("Antigravity part contains both text and functionCall");
|
|
192
|
+
const call = object(part.functionCall, "functionCall");
|
|
193
|
+
const name = optionalString(call.name, "functionCall.name");
|
|
194
|
+
if (!name) throw new AntigravityStreamError("Antigravity function call has no name");
|
|
195
|
+
const id = optionalString(call.id, "functionCall.id") || `agy_${randomUUID()}`;
|
|
196
|
+
if (output.content.some((b) => b.type === "toolCall" && b.id === id)) {
|
|
197
|
+
throw new AntigravityStreamError("Antigravity returned a duplicate function-call ID", { id });
|
|
198
|
+
}
|
|
199
|
+
const args = object(call.args ?? {}, "functionCall.args") as JsonObject;
|
|
200
|
+
closeBlock();
|
|
201
|
+
const toolCall = { type: "toolCall" as const, id, name, arguments: args,
|
|
202
|
+
...(signature ? { thoughtSignature: signature } : {}) };
|
|
203
|
+
const contentIndex = output.content.push(toolCall) - 1;
|
|
204
|
+
stream.push({ type: "toolcall_start", contentIndex, partial: output });
|
|
205
|
+
stream.push({ type: "toolcall_delta", contentIndex, delta: JSON.stringify(args), partial: output });
|
|
206
|
+
stream.push({ type: "toolcall_end", contentIndex, toolCall, partial: output });
|
|
207
|
+
} else if (part.text !== undefined) {
|
|
208
|
+
const text = optionalString(part.text, "part.text")!;
|
|
209
|
+
if (finishReason && text !== "") throw new AntigravityStreamError("Antigravity sent text after the finish reason");
|
|
210
|
+
if (part.thought !== undefined && typeof part.thought !== "boolean") throw new AntigravityStreamError("Invalid Antigravity thought flag");
|
|
211
|
+
const type = part.thought === true ? "thinking" : "text";
|
|
212
|
+
// Separate explicit parts and every signed part. Unindexed, unsigned deltas in
|
|
213
|
+
// later frames can extend the current block, but signatures never move to it.
|
|
214
|
+
if (explicitBoundary || signature) closeBlock();
|
|
215
|
+
if (currentIndex === undefined || output.content[currentIndex]!.type !== type) {
|
|
216
|
+
closeBlock();
|
|
217
|
+
currentIndex = output.content.push(type === "text" ? { type, text: "" } : { type, thinking: "" }) - 1;
|
|
218
|
+
stream.push({ type: type === "text" ? "text_start" : "thinking_start", contentIndex: currentIndex, partial: output });
|
|
219
|
+
}
|
|
220
|
+
const block = output.content[currentIndex]!;
|
|
221
|
+
if (block.type === "text") {
|
|
222
|
+
block.text += text;
|
|
223
|
+
if (signature) block.textSignature = signature;
|
|
224
|
+
stream.push({ type: "text_delta", contentIndex: currentIndex, delta: text, partial: output });
|
|
225
|
+
} else if (block.type === "thinking") {
|
|
226
|
+
block.thinking += text;
|
|
227
|
+
if (signature) block.thinkingSignature = signature;
|
|
228
|
+
stream.push({ type: "thinking_delta", contentIndex: currentIndex, delta: text, partial: output });
|
|
229
|
+
}
|
|
230
|
+
} else {
|
|
231
|
+
throw new AntigravityStreamError("Unsupported Antigravity response part", { keys: Object.keys(part) });
|
|
232
|
+
}
|
|
233
|
+
};
|
|
234
|
+
const readEvent = async (data: string): Promise<boolean> => {
|
|
235
|
+
if (data === "[DONE]") return true;
|
|
236
|
+
let parsed: unknown;
|
|
237
|
+
try { parsed = JSON.parse(data); } catch {
|
|
238
|
+
throw new AntigravityStreamError("Antigravity SSE contains invalid JSON");
|
|
239
|
+
}
|
|
240
|
+
await waitFor(effectiveOptions.onProviderStreamEvent?.(parsed, model), controller);
|
|
241
|
+
controller.signal.throwIfAborted();
|
|
242
|
+
const envelope = object(parsed, "event");
|
|
243
|
+
if (envelope.error !== undefined) {
|
|
244
|
+
throw new AntigravityStreamError(`Antigravity stream error: ${JSON.stringify(envelope.error)}`, { body: envelope as JsonObject });
|
|
245
|
+
}
|
|
246
|
+
const response = object(envelope.response, "response");
|
|
247
|
+
if (response.error !== undefined) {
|
|
248
|
+
throw new AntigravityStreamError(`Antigravity stream error: ${JSON.stringify(response.error)}`, { body: envelope as JsonObject });
|
|
249
|
+
}
|
|
250
|
+
output.responseId ||= optionalString(response.responseId, "responseId") || undefined;
|
|
251
|
+
output.responseModel ||= optionalString(response.modelVersion, "modelVersion") || undefined;
|
|
252
|
+
// Keep upstream provenance without substituting the requested runtime model for a confirmed model.
|
|
253
|
+
if (envelope.traceId !== undefined || envelope.metadata !== undefined) {
|
|
254
|
+
const details: JsonObject = {};
|
|
255
|
+
if (envelope.traceId !== undefined) details.traceId = optionalString(envelope.traceId, "traceId")!;
|
|
256
|
+
if (envelope.metadata !== undefined) details.metadata = object(envelope.metadata, "metadata") as JsonObject;
|
|
257
|
+
const existing = output.diagnostics?.find((d) => d.type === "antigravity.response");
|
|
258
|
+
if (existing) existing.details = { ...existing.details, ...details };
|
|
259
|
+
else (output.diagnostics ??= []).push({ type: "antigravity.response", timestamp: Date.now(), details });
|
|
260
|
+
}
|
|
261
|
+
if (response.usageMetadata !== undefined) readUsage(response.usageMetadata);
|
|
262
|
+
if (response.promptFeedback !== undefined) {
|
|
263
|
+
const feedback = object(response.promptFeedback, "promptFeedback");
|
|
264
|
+
if (feedback.blockReason) throw new AntigravityStreamError("Antigravity blocked the prompt", { promptFeedback: feedback as JsonObject });
|
|
265
|
+
}
|
|
266
|
+
if (response.candidates !== undefined) {
|
|
267
|
+
if (!Array.isArray(response.candidates) || response.candidates.length > 1) throw new AntigravityStreamError("Invalid Antigravity candidates");
|
|
268
|
+
if (response.candidates.length) {
|
|
269
|
+
const candidate = object(response.candidates[0], "candidate");
|
|
270
|
+
if (candidate.index !== undefined && candidate.index !== 0) throw new AntigravityStreamError("Unsupported Antigravity candidate index");
|
|
271
|
+
if (candidate.content !== undefined) {
|
|
272
|
+
const content = object(candidate.content, "content");
|
|
273
|
+
if (!Array.isArray(content.parts)) throw new AntigravityStreamError("Invalid Antigravity content.parts");
|
|
274
|
+
for (let i = 0; i < content.parts.length; i++) readPart(content.parts[i], i > 0);
|
|
275
|
+
}
|
|
276
|
+
if (candidate.finishReason !== undefined) {
|
|
277
|
+
const reason = optionalString(candidate.finishReason, "finishReason")!;
|
|
278
|
+
if (finishReason && finishReason !== reason) throw new AntigravityStreamError("Conflicting Antigravity finish reasons");
|
|
279
|
+
finishReason = reason;
|
|
280
|
+
output.rawStopReason = reason;
|
|
281
|
+
if (reason !== "STOP" && reason !== "MAX_TOKENS") {
|
|
282
|
+
throw new AntigravityStreamError(`Antigravity stopped with: ${reason}`, { candidate: candidate as JsonObject });
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
return false;
|
|
288
|
+
};
|
|
289
|
+
|
|
290
|
+
try {
|
|
291
|
+
controller.signal.throwIfAborted();
|
|
292
|
+
const headMs = deadline(options?.headTimeoutMs ?? options?.timeoutMs, 30_000);
|
|
293
|
+
const idleMs = deadline(options?.idleTimeoutMs ?? options?.timeoutMs, 120_000);
|
|
294
|
+
const credentials = await waitFor(dependencies.resolveCredentials(model, effectiveOptions), controller);
|
|
295
|
+
if (!credentials.token) throw new AntigravityStreamError("Missing Antigravity access token");
|
|
296
|
+
const baseUrl = assertOfficialBaseUrl(credentials.baseUrl ?? (model.baseUrl || DEFAULT_BASE_URL));
|
|
297
|
+
let payload = await waitFor(dependencies.buildPayload(model, context, effectiveOptions), controller);
|
|
298
|
+
const replacement = await waitFor(effectiveOptions.onPayload?.(payload, model), controller);
|
|
299
|
+
if (replacement !== undefined) payload = replacement;
|
|
300
|
+
controller.signal.throwIfAborted();
|
|
301
|
+
const body = JSON.stringify(payload);
|
|
302
|
+
if (body === undefined) throw new AntigravityStreamError("Antigravity payload is not JSON serializable");
|
|
303
|
+
const headers = new Headers({ Authorization: `Bearer ${credentials.token}`, "Content-Type": "application/json", "User-Agent": CLIENT_USER_AGENT });
|
|
304
|
+
for (const source of [model.headers, options?.headers]) {
|
|
305
|
+
for (const [key, value] of Object.entries(source ?? {})) {
|
|
306
|
+
if (value === null) headers.delete(key);
|
|
307
|
+
else headers.set(key, value);
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
const fetching = Promise.resolve((options?.fetch ?? dependencies.fetch ?? globalThis.fetch)(
|
|
311
|
+
`${baseUrl}/v1internal:streamGenerateContent?alt=sse`,
|
|
312
|
+
{ method: "POST", redirect: "error", headers, body, signal: controller.signal },
|
|
313
|
+
));
|
|
314
|
+
// An injected fetch might ignore abort and resolve late; do not leak its body.
|
|
315
|
+
void fetching.then((response) => {
|
|
316
|
+
if (controller.signal.aborted && !reader) void response.body?.cancel().catch(() => {});
|
|
317
|
+
}, () => {});
|
|
318
|
+
const response = await waitFor(fetching, controller, headMs, "response-header");
|
|
319
|
+
reader = response.body?.getReader();
|
|
320
|
+
await waitFor(effectiveOptions.onResponse?.({ status: response.status, headers: Object.fromEntries(response.headers) }, model), controller);
|
|
321
|
+
if (!response.ok) {
|
|
322
|
+
let text = "";
|
|
323
|
+
let bytes = 0;
|
|
324
|
+
const decoder = new TextDecoder();
|
|
325
|
+
if (reader) {
|
|
326
|
+
while (true) {
|
|
327
|
+
const chunk = await waitFor(reader.read(), controller, idleMs, "body-idle");
|
|
328
|
+
if (chunk.done) break;
|
|
329
|
+
bytes += chunk.value.byteLength;
|
|
330
|
+
if (bytes > MAX_ERROR_BYTES) throw new AntigravityStreamError(`Antigravity HTTP ${response.status}: error body exceeds the size limit`, { status: response.status });
|
|
331
|
+
text += decoder.decode(chunk.value, { stream: true });
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
text += decoder.decode();
|
|
335
|
+
let errorBody: unknown = text;
|
|
336
|
+
try { errorBody = JSON.parse(text); } catch { /* Preserve non-JSON HTTP evidence verbatim. */ }
|
|
337
|
+
throw new AntigravityStreamError(`Antigravity HTTP ${response.status}: ${text || response.statusText}`, {
|
|
338
|
+
status: response.status, body: errorBody as JsonObject | string,
|
|
339
|
+
...(response.headers.has("retry-after") ? { retryAfter: response.headers.get("retry-after")! } : {}),
|
|
340
|
+
});
|
|
341
|
+
}
|
|
342
|
+
if (!reader) throw new AntigravityStreamError("Antigravity response has no body");
|
|
343
|
+
if (!/^text\/event-stream(?:\s*;|$)/i.test(response.headers.get("content-type") ?? "")) {
|
|
344
|
+
throw new AntigravityStreamError("Antigravity response is not text/event-stream");
|
|
345
|
+
}
|
|
346
|
+
stream.push({ type: "start", partial: output });
|
|
347
|
+
const sse = new SseDecoder();
|
|
348
|
+
let done = false;
|
|
349
|
+
while (!done) {
|
|
350
|
+
const chunk = await waitFor(reader.read(), controller, idleMs, "body-idle");
|
|
351
|
+
controller.signal.throwIfAborted();
|
|
352
|
+
const frames = chunk.done ? sse.finish() : sse.push(chunk.value);
|
|
353
|
+
for (const data of frames) {
|
|
354
|
+
if (done) throw new AntigravityStreamError("Antigravity sent data after [DONE]");
|
|
355
|
+
done = await readEvent(data);
|
|
356
|
+
}
|
|
357
|
+
if (chunk.done) break;
|
|
358
|
+
}
|
|
359
|
+
controller.signal.throwIfAborted();
|
|
360
|
+
if (!finishReason) throw new AntigravityStreamError("Antigravity stream ended without a finish reason");
|
|
361
|
+
closeBlock();
|
|
362
|
+
output.stopReason = finishReason === "MAX_TOKENS" ? "length"
|
|
363
|
+
: output.content.some((b) => b.type === "toolCall") ? "toolUse" : "stop";
|
|
364
|
+
stream.push({ type: "done", reason: output.stopReason, message: output });
|
|
365
|
+
} catch (error) {
|
|
366
|
+
closeBlock();
|
|
367
|
+
output.stopReason = options?.signal?.aborted ? "aborted" : "error";
|
|
368
|
+
output.errorMessage = output.stopReason === "aborted" ? "Antigravity request aborted"
|
|
369
|
+
: error instanceof Error ? error.message : String(error);
|
|
370
|
+
if (error instanceof AntigravityStreamError) {
|
|
371
|
+
(output.diagnostics ??= []).push({ type: "antigravity.error", timestamp: Date.now(), details: error.evidence,
|
|
372
|
+
error: { name: error.name, message: error.message } });
|
|
373
|
+
}
|
|
374
|
+
stream.push({ type: "error", reason: output.stopReason, error: output });
|
|
375
|
+
} finally {
|
|
376
|
+
options?.signal?.removeEventListener("abort", abort);
|
|
377
|
+
// Do not let a broken source's cancellation promise hold the terminal result open.
|
|
378
|
+
if (reader) {
|
|
379
|
+
void reader.cancel().catch(() => {});
|
|
380
|
+
reader.releaseLock();
|
|
381
|
+
}
|
|
382
|
+
controller.abort();
|
|
383
|
+
stream.end();
|
|
384
|
+
}
|
|
385
|
+
})();
|
|
386
|
+
return stream;
|
|
387
|
+
}
|