/** * Antigravity stream provider. * * Wire protocol, headers, retry/failover behavior, and the per-request * spoofing envelope (sessionId/requestId/labels) are copied from oh-my-pi's * google-gemini-cli provider for the google-antigravity provider. */ import { createHash, randomUUID } from "node:crypto"; import { type Api, type AssistantMessage, type AssistantMessageEventStream, type Context, calculateCost, createAssistantMessageEventStream, type Model, type TextContent, type ThinkingContent, type ThinkingLevel, type ToolCall, } from "@earendil-works/pi-ai"; import { type AntigravitySimpleStreamOptions, buildBaseOptions, clampReasoning, convertMessages, convertTools, type FunctionCallingConfigMode, type GoogleContent, type GoogleThinkingConfig, type GoogleThinkingLevel, headersToRecord, isThinkingPart, mapStopReasonString, mapToolChoice, retainThoughtSignature, sanitizeSurrogates, } from "./helpers.js"; import { type AntigravityModelProfile, getAntigravityProfile } from "./models.js"; import { ANTIGRAVITY_DAILY_ENDPOINT, ANTIGRAVITY_ENDPOINT_FALLBACKS, ANTIGRAVITY_SANDBOX_ENDPOINT, CLAUDE_THINKING_BETA_HEADER, getAntigravityModelWireProfile, getAntigravityUserAgent, } from "./wire.js"; const GOOGLE_THINKING: Partial> = { minimal: 1024, low: 4096, medium: 8192, high: 16384, xhigh: 24575, max: 32768, }; const MIN_OUTPUT_TOKENS = 1024; /** Map a normalized effort to Google's thinkingLevel enum for google-level models. */ function mapEffortToGoogleLevel(effort: ThinkingLevel, minimalIsLow: boolean): GoogleThinkingLevel { if (effort === "minimal") return minimalIsLow ? "LOW" : "MINIMAL"; switch (effort) { case "low": return "LOW"; case "medium": return "MEDIUM"; default: return "HIGH"; } } const FORCED_TOOL_DIRECTIVE = "TOOL-ONLY TURN. This turn accepts a tool call and nothing else; a text reply here is discarded unread and you will be re-prompted. Emit the tool call now.\n"; const MAX_RETRIES = 3; const BASE_DELAY_MS = 1000; const MAX_EMPTY_STREAM_RETRIES = 2; const EMPTY_STREAM_BASE_DELAY_MS = 500; const RATE_LIMIT_BUDGET_MS = 5 * 60 * 1000; let toolCallCounter = 0; interface CloudCodeAssistRequest { project: string; model: string; request: { contents: GoogleContent[]; sessionId?: string; systemInstruction?: { role?: string; parts: { text: string }[] }; generationConfig?: { maxOutputTokens?: number; temperature?: number; topP?: number; topK?: number; minP?: number; presencePenalty?: number; repetitionPenalty?: number; thinkingConfig?: GoogleThinkingConfig; }; tools?: { functionDeclarations: Record[] }[] | undefined; toolConfig?: { functionCallingConfig: { mode: FunctionCallingConfigMode; allowedFunctionNames?: string[]; }; }; labels?: Record; }; requestType?: string; userAgent?: string; requestId?: string; } interface CloudCodeAssistResponseChunk { response?: { candidates?: Array<{ content?: { role: string; parts?: Array<{ text?: string; thought?: boolean; thoughtSignature?: string; functionCall?: { name: string; args: Record; id?: string }; }>; }; finishReason?: string; }>; usageMetadata?: { promptTokenCount?: number; candidatesTokenCount?: number; thoughtsTokenCount?: number; totalTokenCount?: number; cachedContentTokenCount?: number; }; modelVersion?: string; responseId?: string; promptFeedback?: { blockReason?: string; blockReasonMessage?: string }; }; error?: { code?: number; message?: string; status?: string }; traceId?: string; } interface ParsedCredentials { accessToken: string; projectId: string; email?: string; } function parseCredentials(apiKeyRaw: string): ParsedCredentials { let raw: unknown; try { raw = JSON.parse(apiKeyRaw); } catch { throw new Error("Invalid Google Cloud Code Assist credentials. Use /login to re-authenticate."); } if (typeof raw !== "object" || raw === null) { throw new Error("Invalid Google Cloud Code Assist credentials. Use /login to re-authenticate."); } const parsed = raw as { token?: unknown; projectId?: unknown; project_id?: unknown; email?: unknown }; const token = typeof parsed.token === "string" ? parsed.token : undefined; const projectId = typeof parsed.projectId === "string" ? parsed.projectId : typeof parsed.project_id === "string" ? parsed.project_id : undefined; const email = typeof parsed.email === "string" && parsed.email.length > 0 ? parsed.email : undefined; if (!token || !projectId) { throw new Error("Missing token or projectId in Google Cloud credentials. Use /login to re-authenticate."); } return { accessToken: token, projectId, email }; } function sleep(ms: number, signal?: AbortSignal): Promise { return new Promise((resolve, reject) => { if (signal?.aborted) { reject(new Error("Request was aborted")); return; } const timeout = setTimeout(resolve, ms); signal?.addEventListener("abort", () => { clearTimeout(timeout); reject(new Error("Request was aborted")); }); }); } function extractRetryDelay(errorText: string, response?: Response | Headers): number | undefined { const normalizeDelay = (ms: number): number | undefined => (ms > 0 ? Math.ceil(ms + 1000) : undefined); const headers = response instanceof Headers ? response : response?.headers; if (headers) { const retryAfter = headers.get("retry-after"); if (retryAfter) { const retryAfterSeconds = Number(retryAfter); if (Number.isFinite(retryAfterSeconds)) { const delay = normalizeDelay(retryAfterSeconds * 1000); if (delay !== undefined) return delay; } const retryAfterDate = new Date(retryAfter); const retryAfterMs = retryAfterDate.getTime(); if (!Number.isNaN(retryAfterMs)) { const delay = normalizeDelay(retryAfterMs - Date.now()); if (delay !== undefined) return delay; } } const rateLimitReset = headers.get("x-ratelimit-reset"); if (rateLimitReset) { const resetSeconds = Number.parseInt(rateLimitReset, 10); if (!Number.isNaN(resetSeconds)) { const delay = normalizeDelay(resetSeconds * 1000 - Date.now()); if (delay !== undefined) return delay; } } const rateLimitResetAfter = headers.get("x-ratelimit-reset-after"); if (rateLimitResetAfter) { const resetAfterSeconds = Number(rateLimitResetAfter); if (Number.isFinite(resetAfterSeconds)) { const delay = normalizeDelay(resetAfterSeconds * 1000); if (delay !== undefined) return delay; } } } const durationMatch = errorText.match(/reset after (?:(\d+)h)?(?:(\d+)m)?(\d+(?:\.\d+)?)s/i); if (durationMatch) { const hours = durationMatch[1] ? parseInt(durationMatch[1], 10) : 0; const minutes = durationMatch[2] ? parseInt(durationMatch[2], 10) : 0; const seconds = parseFloat(durationMatch[3]); if (!Number.isNaN(seconds)) { const totalMs = ((hours * 60 + minutes) * 60 + seconds) * 1000; const delay = normalizeDelay(totalMs); if (delay !== undefined) return delay; } } const retryInMatch = errorText.match(/Please retry in ([0-9.]+)(ms|s)/i); if (retryInMatch?.[1]) { const value = parseFloat(retryInMatch[1]); if (!Number.isNaN(value) && value > 0) { const ms = retryInMatch[2].toLowerCase() === "ms" ? value : value * 1000; const delay = normalizeDelay(ms); if (delay !== undefined) return delay; } } const retryDelayMatch = errorText.match(/"retryDelay":\s*"([0-9.]+)(ms|s)"/i); if (retryDelayMatch?.[1]) { const value = parseFloat(retryDelayMatch[1]); if (!Number.isNaN(value) && value > 0) { const ms = retryDelayMatch[2].toLowerCase() === "ms" ? value : value * 1000; const delay = normalizeDelay(ms); if (delay !== undefined) return delay; } } return undefined; } function isRetryableError(status: number, errorText: string): boolean { if (status === 408 || status === 429 || status >= 500) { return true; } return /resource.?exhausted|rate.?limit|overloaded|service.?unavailable|other.?side.?closed/i.test(errorText); } function extractErrorMessage(errorText: string): string { try { const parsed = JSON.parse(errorText) as { error?: { message?: string } }; if (parsed.error?.message) { return parsed.error.message; } } catch { // Not JSON, return as-is } return errorText; } function extractGoogleValidationUrl(errorBody: string): string | undefined { if (!errorBody.includes("VALIDATION_REQUIRED")) return undefined; const start = errorBody.indexOf("{"); if (start === -1) return undefined; try { const parsed = JSON.parse(errorBody.slice(start)) as { error?: { details?: Array<{ reason?: string; metadata?: { validation_url?: string } }> }; }; const detail = parsed.error?.details?.find( (d) => d.reason === "VALIDATION_REQUIRED" && typeof d.metadata?.validation_url === "string", ); return detail?.metadata?.validation_url; } catch { return undefined; } } function formatAntigravityApiError(status: number, errorText: string, email?: string): string { const validationUrl = extractGoogleValidationUrl(errorText); if (validationUrl) { const account = email ? ` for ${email}` : ""; return `Cloud Code Assist API error (${status}): Account verification required${account}. Visit ${validationUrl} to continue, then retry your request.`; } return `Cloud Code Assist API error (${status}): ${extractErrorMessage(errorText)}`; } const INT63_MASK = (1n << 63n) - 1n; function formatSignedDecimalSessionId(value: bigint): string { return `-${value.toString()}`; } function deriveSignedDecimalFromHash(text: string): string { const digest = createHash("sha256").update(text).digest(); let value = 0n; for (let index = 0; index < 8; index += 1) { value = (value << 8n) | BigInt(digest[index] ?? 0); } return formatSignedDecimalSessionId(value & INT63_MASK); } function getFirstUserText(context: Context): string | undefined { for (const message of context.messages) { if (message.role !== "user") continue; if (typeof message.content === "string") return message.content; if (Array.isArray(message.content)) { const firstTextPart = message.content.find((item): item is TextContent => item.type === "text"); return firstTextPart?.text; } return undefined; } return undefined; } function deriveAntigravitySessionId(context: Context): string { const text = getFirstUserText(context); if (text && text.trim().length > 0) { return deriveSignedDecimalFromHash(text); } const digest = createHash("sha256").update(randomUUID()).digest(); let value = 0n; for (let index = 0; index < 8; index += 1) { value = (value << 8n) | BigInt(digest[index] ?? 0); } return formatSignedDecimalSessionId(value & INT63_MASK); } interface AntigravitySessionState { agentId: string; trajectoryId: string; sessionId: string; stepIndex: number; lastExecutionId?: string; } const sessionStates = new Map(); function getSessionState(key: string, context: Context): AntigravitySessionState { const existing = sessionStates.get(key); if (existing) return existing; const state: AntigravitySessionState = { agentId: randomUUID(), trajectoryId: randomUUID(), sessionId: deriveAntigravitySessionId(context), stepIndex: 1, }; sessionStates.set(key, state); return state; } function buildAntigravityRequestEnvelope( profile: AntigravityModelProfile, state: AntigravitySessionState, ): { sessionId: string; requestId: string; labels: Record } { state.stepIndex = state.stepIndex + 1; const step = state.stepIndex; const requestId = `agent/${state.agentId}/${Date.now()}/${state.trajectoryId}/${step}`; const labels: Record = {}; if (state.lastExecutionId) labels.last_execution_id = state.lastExecutionId; labels.last_step_index = String(step - 1); if (profile.modelEnum !== undefined) labels.model_enum = profile.modelEnum; labels.trajectory_id = state.trajectoryId; const usageLabel = String(profile.identityClass === "anthropic"); labels.used_claude = usageLabel; labels.used_claude_conservative = usageLabel; return { sessionId: state.sessionId, requestId, labels }; } export interface AntigravityStreamOptions extends AntigravitySimpleStreamOptions { toolChoice?: "auto" | "none" | "any"; topP?: number; topK?: number; minP?: number; presencePenalty?: number; repetitionPenalty?: number; } export function buildRequest( model: Model, context: Context, projectId: string, options: AntigravityStreamOptions = {}, profile: AntigravityModelProfile = getAntigravityProfile(model.id), state: AntigravitySessionState = getSessionState(options?.sessionId ?? "default", context), ): CloudCodeAssistRequest { const contents = convertMessages(model, context); const generationConfig: CloudCodeAssistRequest["request"]["generationConfig"] = {}; if (options.temperature !== undefined) { generationConfig.temperature = options.temperature; } if (options.maxTokens !== undefined) { generationConfig.maxOutputTokens = options.maxTokens; } if (options.topP !== undefined) { generationConfig.topP = options.topP; } if (options.topK !== undefined) { generationConfig.topK = options.topK; } if (options.minP !== undefined) { generationConfig.minP = options.minP; } if (options.presencePenalty !== undefined) { generationConfig.presencePenalty = options.presencePenalty; } if (options.repetitionPenalty !== undefined) { generationConfig.repetitionPenalty = options.repetitionPenalty; } const thinking = profile.thinking; if (model.reasoning && options.reasoning && options.reasoning !== "off") { const effort = clampReasoning(options.reasoning)!; if (thinking.mode === "google-level") { generationConfig.thinkingConfig = { includeThoughts: true, thinkingLevel: mapEffortToGoogleLevel(effort, thinking.minimalLevelIsLow ?? false), }; } else { const customBudgets = options.thinkingBudgets; let thinkingBudget = customBudgets?.[effort] ?? thinking.effortBudgets?.[effort] ?? GOOGLE_THINKING[effort] ?? 0; const maxTokens = Math.min((options.maxTokens ?? model.maxTokens) + thinkingBudget, model.maxTokens); if (maxTokens <= thinkingBudget) { thinkingBudget = Math.max(0, maxTokens - MIN_OUTPUT_TOKENS); } if (thinkingBudget > 0) { generationConfig.thinkingConfig = { includeThoughts: true, thinkingBudget }; generationConfig.maxOutputTokens = maxTokens; } } } else if (model.reasoning && thinking.suppressWhenOff) { // Omit thinkingConfig when off is not special-cased; otherwise CCA // re-applies the per-id baked server default and bills hidden reasoning. generationConfig.thinkingConfig = { includeThoughts: false, ...(thinking.mode === "google-level" ? { thinkingLevel: "MINIMAL" } : { thinkingBudget: 0 }), }; } const request: CloudCodeAssistRequest["request"] = { contents }; if (context.systemPrompt) { request.systemInstruction = { role: "user", parts: [{ text: sanitizeSurrogates(context.systemPrompt) }], }; } if (context.tools && context.tools.length > 0) { // Antigravity / Cloud Code Assist requires the legacy `parameters` field // regardless of model family. request.tools = convertTools(context.tools, true); if (options.toolChoice) { request.toolConfig = { functionCallingConfig: { mode: mapToolChoice(options.toolChoice), }, }; // Cloud Code Assist drops toolConfig on Antigravity's Gemini routes: // the backend answers in text under mode ANY and still emits calls // under NONE. Claude routes implement it, so only Gemini needs the // forced choice restated in the transcript. if (profile.identityClass !== "anthropic" && request.toolConfig?.functionCallingConfig.mode === "ANY") { contents.push({ role: "user", parts: [{ text: FORCED_TOOL_DIRECTIVE }] }); } } // Antigravity's default tool mode is VALIDATED. if (!request.toolConfig) { request.toolConfig = { functionCallingConfig: { mode: "VALIDATED" }, }; } } // Claude on Antigravity always forces VALIDATED, even with no tools declared. if (profile.identityClass === "anthropic") { request.toolConfig = { functionCallingConfig: { mode: "VALIDATED" }, }; } const wireModelId = profile.wireModelId; const wireProfile = getAntigravityModelWireProfile(wireModelId); if (wireProfile) { generationConfig.maxOutputTokens = wireProfile.maxOutputTokens; } const envelope = buildAntigravityRequestEnvelope(profile, state); request.labels = envelope.labels; if (Object.keys(generationConfig).length > 0) { request.generationConfig = generationConfig; } request.sessionId = envelope.sessionId; return { project: projectId, requestId: envelope.requestId, request, model: wireModelId, userAgent: "antigravity", requestType: "agent", }; } export function streamAntigravity( model: Model, context: Context, options?: AntigravityStreamOptions, ): AssistantMessageEventStream { const stream = createAssistantMessageEventStream(); (async () => { const output: AssistantMessage = { role: "assistant", content: [], api: model.api, provider: model.provider, model: model.id, usage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, totalTokens: 0, cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }, }, stopReason: "stop", timestamp: Date.now(), }; try { const apiKeyRaw = options?.apiKey; if (!apiKeyRaw) { throw new Error( "Antigravity requires OAuth authentication. Use /login google-antigravity to authenticate.", ); } const credentials = parseCredentials(apiKeyRaw); const profile = getAntigravityProfile(model.id); const state = getSessionState(options?.sessionId ?? "default", context); const baseUrl = model.baseUrl?.trim(); const cleanUrl = baseUrl ? baseUrl.replace(/\/+$/, "") : ""; let endpoints: string[]; if (cleanUrl && cleanUrl !== ANTIGRAVITY_DAILY_ENDPOINT && cleanUrl !== ANTIGRAVITY_SANDBOX_ENDPOINT) { endpoints = [cleanUrl]; } else { endpoints = [...ANTIGRAVITY_ENDPOINT_FALLBACKS]; } let requestBody = buildRequest(model, context, credentials.projectId, options ?? {}, profile, state); const nextRequestBody = await options?.onPayload?.(requestBody, model); if (nextRequestBody !== undefined) { requestBody = nextRequestBody as CloudCodeAssistRequest; } const requestHeaders: Record = { Authorization: `Bearer ${credentials.accessToken}`, "Content-Type": "application/json", Accept: "text/event-stream", "User-Agent": getAntigravityUserAgent(), ...(profile.identityClass === "anthropic" && model.reasoning ? { "anthropic-beta": CLAUDE_THINKING_BETA_HEADER } : {}), ...options?.headers, }; const requestBodyJson = JSON.stringify(requestBody); let response: Response | undefined; let lastError: Error | undefined; let requestUrl: string | undefined; let endpointIndex = 0; for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) { if (options?.signal?.aborted) { throw new Error("Request was aborted"); } try { const endpoint = endpoints[endpointIndex]; requestUrl = `${endpoint}/v1internal:streamGenerateContent?alt=sse`; response = await fetch(requestUrl, { method: "POST", headers: requestHeaders, body: requestBodyJson, signal: options?.signal, }); await options?.onResponse?.( { status: response.status, headers: headersToRecord(response.headers) }, model, ); if (response.ok) { break; } const errorText = await response.text(); if ((response.status === 403 || response.status === 404) && endpointIndex < endpoints.length - 1) { endpointIndex++; continue; } if (attempt < MAX_RETRIES && isRetryableError(response.status, errorText)) { if (endpointIndex < endpoints.length - 1) { endpointIndex++; } const serverDelay = extractRetryDelay(errorText, response); const delayMs = serverDelay ?? BASE_DELAY_MS * 2 ** attempt; const maxDelayMs = options?.maxRetryDelayMs ?? RATE_LIMIT_BUDGET_MS; if (maxDelayMs > 0 && serverDelay && serverDelay > maxDelayMs) { const delaySeconds = Math.ceil(serverDelay / 1000); throw new Error( `Server requested ${delaySeconds}s retry delay (max: ${Math.ceil(maxDelayMs / 1000)}s). ${extractErrorMessage(errorText)}`, ); } await sleep(delayMs, options?.signal); continue; } throw new Error(formatAntigravityApiError(response.status, errorText, credentials.email)); } catch (error) { if (error instanceof Error) { if (error.name === "AbortError" || error.message === "Request was aborted") { throw new Error("Request was aborted"); } } lastError = error instanceof Error ? error : new Error(String(error)); if (lastError.message === "fetch failed" && lastError.cause instanceof Error) { lastError = new Error(`Network error: ${lastError.cause.message}`); } if (attempt < MAX_RETRIES) { const delayMs = BASE_DELAY_MS * 2 ** attempt; await sleep(delayMs, options?.signal); continue; } throw lastError; } } if (!response || !response.ok) { throw lastError ?? new Error("Failed to get response after retries"); } let started = false; const ensureStarted = () => { if (!started) { stream.push({ type: "start", partial: output }); started = true; } }; const resetOutput = () => { output.content = []; output.usage = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, totalTokens: 0, cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }, }; output.stopReason = "stop"; output.errorMessage = undefined; output.timestamp = Date.now(); started = false; }; const streamResponse = async (activeResponse: Response): Promise => { if (!activeResponse.body) { throw new Error("No response body"); } let hasContent = false; let currentBlock: TextContent | ThinkingContent | null = null; const blocks = output.content; const blockIndex = () => blocks.length - 1; const reader = activeResponse.body.getReader(); const decoder = new TextDecoder(); let buffer = ""; const abortHandler = () => { void reader.cancel().catch(() => {}); }; options?.signal?.addEventListener("abort", abortHandler); try { while (true) { if (options?.signal?.aborted) { throw new Error("Request was aborted"); } const { done, value } = await reader.read(); if (done) break; buffer += decoder.decode(value, { stream: true }); const lines = buffer.split("\n"); buffer = lines.pop() || ""; for (const line of lines) { if (!line.startsWith("data:")) continue; const jsonStr = line.slice(5).trim(); if (!jsonStr) continue; let chunk: CloudCodeAssistResponseChunk; try { chunk = JSON.parse(jsonStr); } catch { continue; } if (chunk.error) { const detail = chunk.error.message || chunk.error.status || "unknown error"; throw new Error(`Cloud Code Assist stream error: ${detail}`); } const responseData = chunk.response; if (!responseData) continue; output.responseId ||= responseData.responseId; if (!responseData.candidates?.length && responseData.promptFeedback?.blockReason) { const detail = responseData.promptFeedback.blockReasonMessage; throw new Error( `Request blocked by Google (${responseData.promptFeedback.blockReason})${detail ? `: ${detail}` : ""}`, ); } const candidate = responseData.candidates?.[0]; if (candidate?.content?.parts) { for (const part of candidate.content.parts) { if (part.text !== undefined && part.text !== "") { hasContent = true; const isThinking = isThinkingPart(part); if ( !currentBlock || (isThinking && currentBlock.type !== "thinking") || (!isThinking && currentBlock.type !== "text") ) { if (currentBlock) { if (currentBlock.type === "text") { stream.push({ type: "text_end", contentIndex: blockIndex(), content: currentBlock.text, partial: output, }); } else { stream.push({ type: "thinking_end", contentIndex: blockIndex(), content: currentBlock.thinking, partial: output, }); } } if (isThinking) { currentBlock = { type: "thinking", thinking: "", thinkingSignature: undefined }; output.content.push(currentBlock); ensureStarted(); stream.push({ type: "thinking_start", contentIndex: blockIndex(), partial: output, }); } else { currentBlock = { type: "text", text: "" }; output.content.push(currentBlock); ensureStarted(); stream.push({ type: "text_start", contentIndex: blockIndex(), partial: output }); } } if (currentBlock.type === "thinking") { currentBlock.thinking += part.text; currentBlock.thinkingSignature = retainThoughtSignature( currentBlock.thinkingSignature, part.thoughtSignature, ); stream.push({ type: "thinking_delta", contentIndex: blockIndex(), delta: part.text, partial: output, }); } else { currentBlock.text += part.text; currentBlock.textSignature = retainThoughtSignature( currentBlock.textSignature, part.thoughtSignature, ); stream.push({ type: "text_delta", contentIndex: blockIndex(), delta: part.text, partial: output, }); } } else if (part.text === "" && part.thoughtSignature && !part.functionCall) { // Terminal thought signatures on empty text parts (e.g. at stream end). if (currentBlock) { if (currentBlock.type === "thinking") { currentBlock.thinkingSignature = retainThoughtSignature( currentBlock.thinkingSignature, part.thoughtSignature, ); } else { currentBlock.textSignature = retainThoughtSignature( currentBlock.textSignature, part.thoughtSignature, ); } } } if (part.functionCall) { hasContent = true; if (currentBlock) { if (currentBlock.type === "text") { stream.push({ type: "text_end", contentIndex: blockIndex(), content: currentBlock.text, partial: output, }); } else { stream.push({ type: "thinking_end", contentIndex: blockIndex(), content: currentBlock.thinking, partial: output, }); } currentBlock = null; } const providedId = part.functionCall.id; const needsNewId = !providedId || output.content.some((b) => b.type === "toolCall" && b.id === providedId); const toolCallId = needsNewId ? `${part.functionCall.name}_${Date.now()}_${++toolCallCounter}` : providedId; const toolCall: ToolCall = { type: "toolCall", id: toolCallId, name: part.functionCall.name || "", arguments: (part.functionCall.args as Record) ?? {}, ...(part.thoughtSignature && { thoughtSignature: part.thoughtSignature }), }; output.content.push(toolCall); ensureStarted(); stream.push({ type: "toolcall_start", contentIndex: blockIndex(), partial: output }); stream.push({ type: "toolcall_delta", contentIndex: blockIndex(), delta: JSON.stringify(toolCall.arguments), partial: output, }); stream.push({ type: "toolcall_end", contentIndex: blockIndex(), toolCall, partial: output, }); } } } if (candidate?.finishReason) { output.stopReason = mapStopReasonString(candidate.finishReason); if (output.content.some((b) => b.type === "toolCall")) { output.stopReason = "toolUse"; } } if (responseData.usageMetadata) { const promptTokens = responseData.usageMetadata.promptTokenCount || 0; const cacheReadTokens = responseData.usageMetadata.cachedContentTokenCount || 0; const thinkingTokens = responseData.usageMetadata.thoughtsTokenCount || 0; output.usage = { input: promptTokens - cacheReadTokens, output: (responseData.usageMetadata.candidatesTokenCount || 0) + thinkingTokens, cacheRead: cacheReadTokens, cacheWrite: 0, totalTokens: responseData.usageMetadata.totalTokenCount || 0, cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0, }, }; calculateCost(model, output.usage); } } } } finally { options?.signal?.removeEventListener("abort", abortHandler); } if (currentBlock) { if (currentBlock.type === "text") { stream.push({ type: "text_end", contentIndex: blockIndex(), content: currentBlock.text, partial: output, }); } else { stream.push({ type: "thinking_end", contentIndex: blockIndex(), content: currentBlock.thinking, partial: output, }); } } return hasContent; }; let receivedContent = false; let currentResponse = response; for (let emptyAttempt = 0; emptyAttempt <= MAX_EMPTY_STREAM_RETRIES; emptyAttempt++) { if (options?.signal?.aborted) { throw new Error("Request was aborted"); } if (emptyAttempt > 0) { const backoffMs = EMPTY_STREAM_BASE_DELAY_MS * 2 ** (emptyAttempt - 1); await sleep(backoffMs, options?.signal); if (!requestUrl) { throw new Error("Missing request URL"); } currentResponse = await fetch(requestUrl, { method: "POST", headers: requestHeaders, body: requestBodyJson, signal: options?.signal, }); await options?.onResponse?.( { status: currentResponse.status, headers: headersToRecord(currentResponse.headers) }, model, ); if (!currentResponse.ok) { const retryErrorText = await currentResponse.text(); throw new Error(`Cloud Code Assist API error (${currentResponse.status}): ${retryErrorText}`); } } const streamed = await streamResponse(currentResponse); if (streamed) { receivedContent = true; break; } if (emptyAttempt < MAX_EMPTY_STREAM_RETRIES) { resetOutput(); } } if (!receivedContent) { throw new Error("Cloud Code Assist API returned an empty response"); } if (options?.signal?.aborted) { throw new Error("Request was aborted"); } if (output.stopReason === "aborted" || output.stopReason === "error") { throw new Error(output.errorMessage ?? "An unknown error occurred"); } state.lastExecutionId = output.responseId; stream.push({ type: "done", reason: output.stopReason, message: output }); stream.end(); } catch (error) { output.stopReason = options?.signal?.aborted ? "aborted" : "error"; output.errorMessage = error instanceof Error ? error.message : JSON.stringify(error); stream.push({ type: "error", reason: output.stopReason, error: output }); stream.end(); } })(); return stream; } export function streamSimpleAntigravity( model: Model, context: Context, options?: AntigravitySimpleStreamOptions, ): AssistantMessageEventStream { const apiKey = options?.apiKey; if (!apiKey) { throw new Error("Antigravity requires OAuth authentication. Use /login google-antigravity to authenticate."); } const base = buildBaseOptions(model, options, apiKey); return streamAntigravity(model, context, { ...base, reasoning: options?.reasoning, thinkingBudgets: options?.thinkingBudgets, }); }