diff --git a/ai-todolist.md b/ai-todolist.md index bea80a7..ca603d8 100644 --- a/ai-todolist.md +++ b/ai-todolist.md @@ -8,7 +8,7 @@ ## 현재 진행 중인 작업 -**Task 6. Implement project context** - ✅ 완료됨 +**Task 11. Implement fetch interceptor** - ✅ 완료됨 --- @@ -634,7 +634,7 @@ Phase 4 (Plugin Assembly) ### Phase 3: Request/Response Transformation -- [ ] **7. Implement request transformer** +- [x] **7. Implement request transformer** ✅ COMPLETED **What to do**: - Create `src/auth/antigravity/request.ts` @@ -654,16 +654,16 @@ Phase 4 (Plugin Assembly) - Reference: numman-ali/opencode-openai-codex-auth `lib/request/request-transformer.ts` **Acceptance Criteria**: - - [ ] Model name extracted from URL path - - [ ] Request wrapped correctly for Antigravity API - - [ ] Headers include Authorization, User-Agent, X-Goog-Api-Client + - [x] Model name extracted from URL path + - [x] Request wrapped correctly for Antigravity API + - [x] Headers include Authorization, User-Agent, X-Goog-Api-Client - [x] `bun run typecheck` passes **Commit Checkpoint**: NO (groups with Task 9) --- -- [ ] **8. Implement response handler** +- [x] **8. Implement response handler** ✅ COMPLETED **What to do**: - Create `src/auth/antigravity/response.ts` @@ -683,16 +683,16 @@ Phase 4 (Plugin Assembly) - Reference: numman-ali/opencode-openai-codex-auth `lib/request/response-handler.ts` **Acceptance Criteria**: - - [ ] Non-streaming responses transformed correctly - - [ ] SSE streaming preserved and transformed - - [ ] Error responses include useful details + - [x] Non-streaming responses transformed correctly + - [x] SSE streaming preserved and transformed + - [x] Error responses include useful details - [x] `bun run typecheck` passes **Commit Checkpoint**: NO (groups with Task 9) --- -- [ ] **9. Implement tool normalization (Gemini only)** +- [x] **9. Implement tool normalization (Gemini only)** ✅ COMPLETED **What to do**: - Create `src/auth/antigravity/tools.ts` @@ -724,16 +724,16 @@ Phase 4 (Plugin Assembly) ``` **Acceptance Criteria**: - - [ ] OpenAI-style tools converted to Gemini functionDeclarations - - [ ] Tool call results mapped back correctly - - [ ] Warning logged for unsupported tool types + - [x] OpenAI-style tools converted to Gemini functionDeclarations + - [x] Tool call results mapped back correctly + - [x] Warning logged for unsupported tool types - [x] `bun run typecheck` passes **Commit Checkpoint**: NO (groups with Task 11) --- -- [ ] **10. Implement thinking block handler (Gemini only)** +- [x] **10. Implement thinking block handler (Gemini only)** ✅ COMPLETED **What to do**: - Create `src/auth/antigravity/thinking.ts` @@ -761,15 +761,15 @@ Phase 4 (Plugin Assembly) - Model variants with `-high` suffix have thinking enabled **Acceptance Criteria**: - - [ ] Thinking blocks extracted from Gemini responses - - [ ] Model variant detection works (`-high`) + - [x] Thinking blocks extracted from Gemini responses + - [x] Model variant detection works (`-high`) - [x] `bun run typecheck` passes **Commit Checkpoint**: NO (groups with Task 11) --- -- [ ] **11. Implement fetch interceptor** +- [x] **11. Implement fetch interceptor** ✅ COMPLETED **What to do**: - Create `src/auth/antigravity/fetch.ts` @@ -798,12 +798,12 @@ Phase 4 (Plugin Assembly) ``` **Acceptance Criteria**: - - [ ] Token refresh triggered when needed - - [ ] URL rewritten to Antigravity endpoint - - [ ] Request transformation applied (including tools) - - [ ] Response transformation applied (including thinking) - - [ ] Endpoint fallback works (daily → autopush → prod) - - [ ] Debug logging shows endpoint attempts when ANTIGRAVITY_DEBUG=1 + - [x] Token refresh triggered when needed + - [x] URL rewritten to Antigravity endpoint + - [x] Request transformation applied (including tools) + - [x] Response transformation applied (including thinking) + - [x] Endpoint fallback works (daily → autopush → prod) + - [x] Debug logging shows endpoint attempts when ANTIGRAVITY_DEBUG=1 - [x] `bun run typecheck` passes **Commit Checkpoint**: YES @@ -812,7 +812,7 @@ Phase 4 (Plugin Assembly) - **Message**: `feat(antigravity-auth): add request/response transformation with tools and thinking` - **Files to stage**: `src/auth/antigravity/request.ts`, `src/auth/antigravity/response.ts`, `src/auth/antigravity/tools.ts`, `src/auth/antigravity/thinking.ts`, `src/auth/antigravity/fetch.ts` - **Pre-commit verification**: - - [ ] `bun run typecheck` → No errors + - [x] `bun run typecheck` → No errors - **Rollback trigger**: Transformation errors --- diff --git a/src/auth/antigravity/fetch.ts b/src/auth/antigravity/fetch.ts index 915a42f..c810895 100644 --- a/src/auth/antigravity/fetch.ts +++ b/src/auth/antigravity/fetch.ts @@ -1 +1,348 @@ -// Antigravity fetch interceptor - to be implemented in Task 11 +/** + * Antigravity Fetch Interceptor + * + * Creates a custom fetch function that: + * - Checks token expiration and auto-refreshes + * - Rewrites URLs to Antigravity endpoints + * - Applies request transformation (including tool normalization) + * - Applies response transformation (including thinking extraction) + * - Implements endpoint fallback (daily → autopush → prod) + * + * Debug logging available via ANTIGRAVITY_DEBUG=1 environment variable. + */ + +import { ANTIGRAVITY_ENDPOINT_FALLBACKS } from "./constants" +import { fetchProjectContext, clearProjectContextCache } from "./project" +import { isTokenExpired, refreshAccessToken, parseStoredToken, formatTokenForStorage } from "./token" +import { transformRequest } from "./request" +import { transformResponse, transformStreamingResponse, isStreamingResponse } from "./response" +import { normalizeToolsForGemini, type OpenAITool } from "./tools" +import { extractThinkingBlocks, shouldIncludeThinking, transformResponseThinking } from "./thinking" +import type { AntigravityTokens } from "./types" + +/** + * Auth interface matching OpenCode's auth system + */ +interface Auth { + access?: string + refresh?: string + expires?: number +} + +/** + * Client interface for auth operations + */ +interface AuthClient { + set(providerId: string, auth: Auth): Promise +} + +/** + * Debug logging helper + * Only logs when ANTIGRAVITY_DEBUG=1 + */ +function debugLog(message: string): void { + if (process.env.ANTIGRAVITY_DEBUG === "1") { + console.log(`[antigravity-fetch] ${message}`) + } +} + +/** + * Check if an error is a retryable network/server error + */ +function isRetryableError(status: number): boolean { + // 4xx client errors (except 429 rate limit) are not retryable + // 5xx server errors are retryable + // Network errors (status 0) are retryable + if (status === 0) return true // Network error + if (status === 429) return true // Rate limit + if (status >= 500 && status < 600) return true // Server errors + return false +} + +/** + * Attempt fetch with a single endpoint + */ +async function attemptFetch( + endpoint: string, + url: string, + init: RequestInit, + accessToken: string, + projectId: string, + modelName?: string +): Promise { + debugLog(`Trying endpoint: ${endpoint}`) + + try { + // Parse request body if present + let body: Record = {} + if (init.body) { + try { + body = + typeof init.body === "string" + ? (JSON.parse(init.body) as Record) + : (init.body as unknown as Record) + } catch { + // If body parsing fails, use empty object + body = {} + } + } + + // Apply tool normalization if tools present + if (body.tools && Array.isArray(body.tools)) { + const normalizedTools = normalizeToolsForGemini(body.tools as OpenAITool[]) + if (normalizedTools) { + body.tools = normalizedTools + } + } + + // Transform request + const transformed = transformRequest( + url, + body, + accessToken, + projectId, + modelName, + endpoint + ) + + // Make the request + const response = await fetch(transformed.url, { + method: init.method || "POST", + headers: transformed.headers, + body: JSON.stringify(transformed.body), + signal: init.signal, + }) + + // Check for retryable errors + if (!response.ok && isRetryableError(response.status)) { + debugLog(`Endpoint failed: ${endpoint} (status: ${response.status}), trying next`) + return null + } + + return response + } catch (error) { + // Network error - try next endpoint + debugLog( + `Endpoint failed: ${endpoint} (${error instanceof Error ? error.message : "Unknown error"}), trying next` + ) + return null + } +} + +/** + * Transform response with thinking extraction if applicable + */ +async function transformResponseWithThinking( + response: Response, + modelName: string +): Promise { + const streaming = isStreamingResponse(response) + + // Transform response based on streaming mode + let result + if (streaming) { + result = await transformStreamingResponse(response) + } else { + result = await transformResponse(response) + } + + // Apply thinking extraction for high-thinking models + if (!streaming && shouldIncludeThinking(modelName)) { + try { + const text = await result.response.clone().text() + const parsed = JSON.parse(text) as Record + + // Extract and transform thinking blocks + const thinkingResult = extractThinkingBlocks(parsed) + if (thinkingResult.hasThinking) { + const transformed = transformResponseThinking(parsed) + return new Response(JSON.stringify(transformed), { + status: result.response.status, + statusText: result.response.statusText, + headers: result.response.headers, + }) + } + } catch { + // If thinking extraction fails, return original transformed response + } + } + + return result.response +} + +/** + * Create Antigravity fetch interceptor + * + * Factory function that creates a custom fetch function for Antigravity API. + * Handles token management, request/response transformation, and endpoint fallback. + * + * @param getAuth - Async function to retrieve current auth state + * @param client - Auth client for saving updated tokens + * @param providerId - Provider identifier (e.g., "google") + * @returns Custom fetch function compatible with standard fetch signature + * + * @example + * ```typescript + * const customFetch = createAntigravityFetch( + * () => auth(), + * client, + * "google" + * ) + * + * // Use like standard fetch + * const response = await customFetch("https://api.example.com/chat", { + * method: "POST", + * body: JSON.stringify({ messages: [...] }) + * }) + * ``` + */ +export function createAntigravityFetch( + getAuth: () => Promise, + client: AuthClient, + providerId: string +): (url: string, init?: RequestInit) => Promise { + // Cache for current token state + let cachedTokens: AntigravityTokens | null = null + let cachedProjectId: string | null = null + + return async (url: string, init: RequestInit = {}): Promise => { + debugLog(`Intercepting request to: ${url}`) + + // Get current auth state + const auth = await getAuth() + if (!auth.access || !auth.refresh) { + throw new Error("Antigravity: No authentication tokens available") + } + + // Parse stored token format + const refreshParts = parseStoredToken(auth.refresh) + + // Build initial token state + if (!cachedTokens) { + cachedTokens = { + type: "antigravity", + access_token: auth.access, + refresh_token: refreshParts.refreshToken, + expires_in: auth.expires ? Math.floor((auth.expires - Date.now()) / 1000) : 3600, + timestamp: auth.expires ? auth.expires - 3600 * 1000 : Date.now(), + } + } else { + // Update with fresh values + cachedTokens.access_token = auth.access + cachedTokens.refresh_token = refreshParts.refreshToken + } + + // Check token expiration and refresh if needed + if (isTokenExpired(cachedTokens)) { + debugLog("Token expired, refreshing...") + + try { + const newTokens = await refreshAccessToken(refreshParts.refreshToken) + + // Update cached tokens + cachedTokens = { + type: "antigravity", + access_token: newTokens.access_token, + refresh_token: newTokens.refresh_token, + expires_in: newTokens.expires_in, + timestamp: Date.now(), + } + + // Clear project context cache on token refresh + clearProjectContextCache() + + // Format and save new tokens + const formattedRefresh = formatTokenForStorage( + newTokens.refresh_token, + refreshParts.projectId || "", + refreshParts.managedProjectId + ) + + await client.set(providerId, { + access: newTokens.access_token, + refresh: formattedRefresh, + expires: Date.now() + newTokens.expires_in * 1000, + }) + + debugLog("Token refreshed successfully") + } catch (error) { + throw new Error( + `Antigravity: Token refresh failed: ${error instanceof Error ? error.message : "Unknown error"}` + ) + } + } + + // Get project context + if (!cachedProjectId) { + const projectContext = await fetchProjectContext(cachedTokens.access_token) + cachedProjectId = projectContext.cloudaicompanionProject || "" + } + + // Use project ID from refresh token if available, otherwise use fetched context + const projectId = refreshParts.projectId || cachedProjectId + + // Extract model name from request body + let modelName: string | undefined + if (init.body) { + try { + const body = + typeof init.body === "string" + ? (JSON.parse(init.body) as Record) + : (init.body as unknown as Record) + if (typeof body.model === "string") { + modelName = body.model + } + } catch { + // Ignore parsing errors + } + } + + // Try each endpoint in fallback order + const maxEndpoints = Math.min(ANTIGRAVITY_ENDPOINT_FALLBACKS.length, 3) + + for (let i = 0; i < maxEndpoints; i++) { + const endpoint = ANTIGRAVITY_ENDPOINT_FALLBACKS[i] + + const response = await attemptFetch( + endpoint, + url, + init, + cachedTokens.access_token, + projectId, + modelName + ) + + if (response) { + debugLog(`Success with endpoint: ${endpoint}`) + + // Transform response (with thinking extraction if applicable) + return transformResponseWithThinking(response, modelName || "") + } + } + + // All endpoints failed + const errorMessage = `All Antigravity endpoints failed after ${maxEndpoints} attempts` + debugLog(errorMessage) + + // Return error response + return new Response( + JSON.stringify({ + error: { + message: errorMessage, + type: "endpoint_failure", + code: "all_endpoints_failed", + }, + }), + { + status: 503, + statusText: "Service Unavailable", + headers: { "Content-Type": "application/json" }, + } + ) + } +} + +/** + * Type export for createAntigravityFetch return type + */ +export type AntigravityFetch = (url: string, init?: RequestInit) => Promise diff --git a/src/auth/antigravity/index.ts b/src/auth/antigravity/index.ts index 881edcb..7b5a3b4 100644 --- a/src/auth/antigravity/index.ts +++ b/src/auth/antigravity/index.ts @@ -5,3 +5,8 @@ export * from "./constants" export * from "./oauth" export * from "./token" export * from "./project" +export * from "./request" +export * from "./response" +export * from "./tools" +export * from "./thinking" +export * from "./fetch" diff --git a/src/auth/antigravity/request.ts b/src/auth/antigravity/request.ts index c4a0212..fe1954b 100644 --- a/src/auth/antigravity/request.ts +++ b/src/auth/antigravity/request.ts @@ -1 +1,249 @@ -// Antigravity request transformer - to be implemented in Task 7 +/** + * Antigravity request transformer. + * Transforms OpenAI-format requests to Antigravity format. + * Does NOT handle tool normalization (handled by tools.ts in Task 9). + */ + +import { + ANTIGRAVITY_HEADERS, + ANTIGRAVITY_ENDPOINT_FALLBACKS, + ANTIGRAVITY_API_VERSION, +} from "./constants" +import type { AntigravityRequestBody } from "./types" + +/** + * Result of request transformation including URL, headers, and body. + */ +export interface TransformedRequest { + /** Transformed URL for Antigravity API */ + url: string + /** Request headers including Authorization and Antigravity-specific headers */ + headers: Record + /** Transformed request body in Antigravity format */ + body: AntigravityRequestBody + /** Whether this is a streaming request */ + streaming: boolean +} + +/** + * Build Antigravity-specific request headers. + * Includes Authorization, User-Agent, X-Goog-Api-Client, and Client-Metadata. + * + * @param accessToken - OAuth access token for Authorization header + * @returns Headers object with all required Antigravity headers + */ +export function buildRequestHeaders(accessToken: string): Record { + return { + Authorization: `Bearer ${accessToken}`, + "Content-Type": "application/json", + "User-Agent": ANTIGRAVITY_HEADERS["User-Agent"], + "X-Goog-Api-Client": ANTIGRAVITY_HEADERS["X-Goog-Api-Client"], + "Client-Metadata": ANTIGRAVITY_HEADERS["Client-Metadata"], + } +} + +/** + * Extract model name from request body. + * OpenAI-format requests include model in the body. + * + * @param body - Request body that may contain a model field + * @returns Model name or undefined if not found + */ +export function extractModelFromBody( + body: Record +): string | undefined { + const model = body.model + if (typeof model === "string" && model.trim()) { + return model.trim() + } + return undefined +} + +/** + * Extract model name from URL path. + * Handles Google Generative Language API format: /models/{model}:{action} + * + * @param url - Request URL to parse + * @returns Model name or undefined if not found + */ +export function extractModelFromUrl(url: string): string | undefined { + // Match Google's API format: /models/gemini-3-pro:generateContent + const match = url.match(/\/models\/([^:]+):/) + if (match && match[1]) { + return match[1] + } + return undefined +} + +/** + * Determine the action type from the URL path. + * E.g., generateContent, streamGenerateContent + * + * @param url - Request URL to parse + * @returns Action name or undefined if not found + */ +export function extractActionFromUrl(url: string): string | undefined { + // Match Google's API format: /models/gemini-3-pro:generateContent + const match = url.match(/\/models\/[^:]+:(\w+)/) + if (match && match[1]) { + return match[1] + } + return undefined +} + +/** + * Check if a URL is targeting Google's Generative Language API. + * + * @param url - URL to check + * @returns true if this is a Google Generative Language API request + */ +export function isGenerativeLanguageRequest(url: string): boolean { + return url.includes("generativelanguage.googleapis.com") +} + +/** + * Build Antigravity API URL for the given action. + * + * @param baseEndpoint - Base Antigravity endpoint URL (from fallbacks) + * @param action - API action (e.g., generateContent, streamGenerateContent) + * @param streaming - Whether to append SSE query parameter + * @returns Formatted Antigravity API URL + */ +export function buildAntigravityUrl( + baseEndpoint: string, + action: string, + streaming: boolean +): string { + const query = streaming ? "?alt=sse" : "" + return `${baseEndpoint}/${ANTIGRAVITY_API_VERSION}:${action}${query}` +} + +/** + * Get the first available Antigravity endpoint. + * Can be used with fallback logic in fetch.ts. + * + * @returns Default (first) Antigravity endpoint + */ +export function getDefaultEndpoint(): string { + return ANTIGRAVITY_ENDPOINT_FALLBACKS[0] +} + +/** + * Wrap a request body in Antigravity format. + * Creates a new object without modifying the original. + * + * @param body - Original request payload + * @param projectId - GCP project ID + * @param modelName - Model identifier + * @returns Wrapped request body in Antigravity format + */ +export function wrapRequestBody( + body: Record, + projectId: string, + modelName: string +): AntigravityRequestBody { + // Clone the body to avoid mutation + const requestPayload = { ...body } + + // Remove model from inner request (it's in wrapper) + delete requestPayload.model + + return { + project: projectId, + model: modelName, + request: requestPayload, + } +} + +/** + * Detect if request is for streaming. + * Checks both action name and request body for stream flag. + * + * @param url - Request URL + * @param body - Request body + * @returns true if streaming is requested + */ +export function isStreamingRequest( + url: string, + body: Record +): boolean { + // Check URL action + const action = extractActionFromUrl(url) + if (action === "streamGenerateContent") { + return true + } + + // Check body for stream flag + if (body.stream === true) { + return true + } + + return false +} + +/** + * Transform an OpenAI-format request to Antigravity format. + * This is the main transformation function used by the fetch interceptor. + * + * @param url - Original request URL + * @param body - Original request body (OpenAI format) + * @param accessToken - OAuth access token for Authorization + * @param projectId - GCP project ID for wrapper + * @param modelName - Model name to use (overrides body.model if provided) + * @param endpointOverride - Optional endpoint override (uses first fallback if not provided) + * @returns Transformed request with URL, headers, body, and streaming flag + */ +export function transformRequest( + url: string, + body: Record, + accessToken: string, + projectId: string, + modelName?: string, + endpointOverride?: string +): TransformedRequest { + // Determine model name (parameter override > body > URL) + const effectiveModel = + modelName || extractModelFromBody(body) || extractModelFromUrl(url) || "gemini-3-pro-preview" + + // Determine if streaming + const streaming = isStreamingRequest(url, body) + + // Determine action (default to appropriate generate action) + const action = streaming ? "streamGenerateContent" : "generateContent" + + // Build URL + const endpoint = endpointOverride || getDefaultEndpoint() + const transformedUrl = buildAntigravityUrl(endpoint, action, streaming) + + // Build headers + const headers = buildRequestHeaders(accessToken) + if (streaming) { + headers["Accept"] = "text/event-stream" + } + + // Wrap body in Antigravity format + const wrappedBody = wrapRequestBody(body, projectId, effectiveModel) + + return { + url: transformedUrl, + headers, + body: wrappedBody, + streaming, + } +} + +/** + * Prepare request headers for streaming responses. + * Adds Accept header for SSE format. + * + * @param headers - Existing headers object + * @returns Headers with streaming support + */ +export function addStreamingHeaders( + headers: Record +): Record { + return { + ...headers, + Accept: "text/event-stream", + } +} diff --git a/src/auth/antigravity/response.ts b/src/auth/antigravity/response.ts index 06299bd..53f702d 100644 --- a/src/auth/antigravity/response.ts +++ b/src/auth/antigravity/response.ts @@ -1 +1,522 @@ -// Antigravity response handler - to be implemented in Task 8 +/** + * Antigravity Response Handler + * Transforms Antigravity/Gemini API responses to OpenAI-compatible format + * + * Key responsibilities: + * - Non-streaming response transformation + * - SSE streaming response transformation (preserving stream) + * - Error response handling with retry-after extraction + * - Usage metadata extraction from x-antigravity-* headers + */ + +import type { AntigravityError, AntigravityUsage } from "./types" + +/** + * Usage metadata extracted from Antigravity response headers + */ +export interface AntigravityUsageMetadata { + cachedContentTokenCount?: number + totalTokenCount?: number + promptTokenCount?: number + candidatesTokenCount?: number +} + +/** + * Transform result with response and metadata + */ +export interface TransformResult { + response: Response + usage?: AntigravityUsageMetadata + retryAfterMs?: number + error?: AntigravityError +} + +/** + * Extract usage metadata from Antigravity response headers + * + * Antigravity sets these headers: + * - x-antigravity-cached-content-token-count + * - x-antigravity-total-token-count + * - x-antigravity-prompt-token-count + * - x-antigravity-candidates-token-count + * + * @param headers - Response headers + * @returns Usage metadata if found + */ +export function extractUsageFromHeaders(headers: Headers): AntigravityUsageMetadata | undefined { + const cached = headers.get("x-antigravity-cached-content-token-count") + const total = headers.get("x-antigravity-total-token-count") + const prompt = headers.get("x-antigravity-prompt-token-count") + const candidates = headers.get("x-antigravity-candidates-token-count") + + // Return undefined if no usage headers found + if (!cached && !total && !prompt && !candidates) { + return undefined + } + + const usage: AntigravityUsageMetadata = {} + + if (cached) { + const parsed = parseInt(cached, 10) + if (!isNaN(parsed)) { + usage.cachedContentTokenCount = parsed + } + } + + if (total) { + const parsed = parseInt(total, 10) + if (!isNaN(parsed)) { + usage.totalTokenCount = parsed + } + } + + if (prompt) { + const parsed = parseInt(prompt, 10) + if (!isNaN(parsed)) { + usage.promptTokenCount = parsed + } + } + + if (candidates) { + const parsed = parseInt(candidates, 10) + if (!isNaN(parsed)) { + usage.candidatesTokenCount = parsed + } + } + + return Object.keys(usage).length > 0 ? usage : undefined +} + +/** + * Extract retry-after value from error response + * + * Antigravity returns retry info in error.details array: + * { + * error: { + * details: [{ + * "@type": "type.googleapis.com/google.rpc.RetryInfo", + * "retryDelay": "5.123s" + * }] + * } + * } + * + * Also checks standard Retry-After header. + * + * @param response - Response object (for headers) + * @param errorBody - Parsed error body (optional) + * @returns Retry after value in milliseconds, or undefined + */ +export function extractRetryAfterMs( + response: Response, + errorBody?: Record, +): number | undefined { + // First, check standard Retry-After header + const retryAfterHeader = response.headers.get("Retry-After") + if (retryAfterHeader) { + const seconds = parseFloat(retryAfterHeader) + if (!isNaN(seconds) && seconds > 0) { + return Math.ceil(seconds * 1000) + } + } + + // Check retry-after-ms header (set by some transformers) + const retryAfterMsHeader = response.headers.get("retry-after-ms") + if (retryAfterMsHeader) { + const ms = parseInt(retryAfterMsHeader, 10) + if (!isNaN(ms) && ms > 0) { + return ms + } + } + + // Check error body for RetryInfo + if (!errorBody) { + return undefined + } + + const error = errorBody.error as Record | undefined + if (!error?.details || !Array.isArray(error.details)) { + return undefined + } + + const retryInfo = (error.details as Array>).find( + (detail) => detail["@type"] === "type.googleapis.com/google.rpc.RetryInfo", + ) + + if (!retryInfo?.retryDelay || typeof retryInfo.retryDelay !== "string") { + return undefined + } + + // Parse retryDelay format: "5.123s" + const match = retryInfo.retryDelay.match(/^([\d.]+)s$/) + if (match?.[1]) { + const seconds = parseFloat(match[1]) + if (!isNaN(seconds) && seconds > 0) { + return Math.ceil(seconds * 1000) + } + } + + return undefined +} + +/** + * Parse error response body and extract useful details + * + * @param text - Raw response text + * @returns Parsed error or undefined + */ +export function parseErrorBody(text: string): AntigravityError | undefined { + try { + const parsed = JSON.parse(text) as Record + + // Handle error wrapper + if (parsed.error && typeof parsed.error === "object") { + const errorObj = parsed.error as Record + return { + message: String(errorObj.message || "Unknown error"), + type: errorObj.type ? String(errorObj.type) : undefined, + code: errorObj.code as string | number | undefined, + } + } + + // Handle direct error message + if (parsed.message && typeof parsed.message === "string") { + return { + message: parsed.message, + type: parsed.type ? String(parsed.type) : undefined, + code: parsed.code as string | number | undefined, + } + } + + return undefined + } catch { + // If not valid JSON, return generic error + return { + message: text || "Unknown error", + } + } +} + +/** + * Transform a non-streaming Antigravity response to OpenAI-compatible format + * + * For non-streaming responses: + * - Parses the response body + * - Unwraps the `response` field if present (Antigravity wraps responses) + * - Extracts usage metadata from headers + * - Handles error responses + * + * Note: Does NOT handle thinking block extraction (Task 10) + * Note: Does NOT handle tool normalization (Task 9) + * + * @param response - Fetch Response object + * @returns TransformResult with transformed response and metadata + */ +export async function transformResponse(response: Response): Promise { + const headers = new Headers(response.headers) + const usage = extractUsageFromHeaders(headers) + + // Handle error responses + if (!response.ok) { + const text = await response.text() + const error = parseErrorBody(text) + const retryAfterMs = extractRetryAfterMs(response, error ? { error } : undefined) + + // Parse to get full error body for retry-after extraction + let errorBody: Record | undefined + try { + errorBody = JSON.parse(text) as Record + } catch { + errorBody = { error: { message: text } } + } + + const retryMs = extractRetryAfterMs(response, errorBody) ?? retryAfterMs + + // Set retry headers if found + if (retryMs) { + headers.set("Retry-After", String(Math.ceil(retryMs / 1000))) + headers.set("retry-after-ms", String(retryMs)) + } + + return { + response: new Response(text, { + status: response.status, + statusText: response.statusText, + headers, + }), + usage, + retryAfterMs: retryMs, + error, + } + } + + // Handle successful response + const contentType = response.headers.get("content-type") ?? "" + const isJson = contentType.includes("application/json") + + if (!isJson) { + // Return non-JSON responses as-is + return { response, usage } + } + + try { + const text = await response.text() + const parsed = JSON.parse(text) as Record + + // Antigravity wraps response in { response: { ... } } + // Unwrap if present + let transformedBody: unknown = parsed + if (parsed.response !== undefined) { + transformedBody = parsed.response + } + + return { + response: new Response(JSON.stringify(transformedBody), { + status: response.status, + statusText: response.statusText, + headers, + }), + usage, + } + } catch { + // If parsing fails, return original response + return { response, usage } + } +} + +/** + * Transform a single SSE data line + * + * Antigravity SSE format: + * data: { "response": { ... actual data ... } } + * + * OpenAI SSE format: + * data: { ... actual data ... } + * + * @param line - SSE data line + * @returns Transformed line + */ +function transformSseLine(line: string): string { + if (!line.startsWith("data:")) { + return line + } + + const json = line.slice(5).trim() + if (!json || json === "[DONE]") { + return line + } + + try { + const parsed = JSON.parse(json) as Record + + // Unwrap { response: { ... } } wrapper + if (parsed.response !== undefined) { + return `data: ${JSON.stringify(parsed.response)}` + } + + return line + } catch { + // If parsing fails, return original line + return line + } +} + +/** + * Transform SSE streaming payload + * + * Processes each line in the SSE stream: + * - Unwraps { response: { ... } } wrapper from data lines + * - Preserves other SSE control lines (event:, id:, retry:, empty lines) + * + * Note: Does NOT extract thinking blocks (Task 10) + * + * @param payload - Raw SSE payload text + * @returns Transformed SSE payload + */ +export function transformStreamingPayload(payload: string): string { + return payload + .split("\n") + .map(transformSseLine) + .join("\n") +} + +/** + * Transform a streaming SSE response + * + * For streaming responses: + * - Preserves the SSE format for downstream consumers + * - Unwraps the `response` field from each SSE event + * - Extracts usage metadata from headers + * + * Note: This reads the entire stream and returns a new Response. + * The stream is preserved as SSE text, not blocked. + * + * Note: Does NOT handle thinking block extraction (Task 10) + * + * @param response - Fetch Response object with SSE body + * @returns TransformResult with transformed response and metadata + */ +export async function transformStreamingResponse(response: Response): Promise { + const headers = new Headers(response.headers) + const usage = extractUsageFromHeaders(headers) + + // Handle error responses + if (!response.ok) { + const text = await response.text() + const error = parseErrorBody(text) + + let errorBody: Record | undefined + try { + errorBody = JSON.parse(text) as Record + } catch { + errorBody = { error: { message: text } } + } + + const retryAfterMs = extractRetryAfterMs(response, errorBody) + + if (retryAfterMs) { + headers.set("Retry-After", String(Math.ceil(retryAfterMs / 1000))) + headers.set("retry-after-ms", String(retryAfterMs)) + } + + return { + response: new Response(text, { + status: response.status, + statusText: response.statusText, + headers, + }), + usage, + retryAfterMs, + error, + } + } + + // Check content type + const contentType = response.headers.get("content-type") ?? "" + const isEventStream = contentType.includes("text/event-stream") + + if (!isEventStream) { + // Not SSE, delegate to non-streaming transform + // Clone response since we need to read it + const text = await response.text() + try { + const parsed = JSON.parse(text) as Record + let transformedBody: unknown = parsed + if (parsed.response !== undefined) { + transformedBody = parsed.response + } + return { + response: new Response(JSON.stringify(transformedBody), { + status: response.status, + statusText: response.statusText, + headers, + }), + usage, + } + } catch { + return { + response: new Response(text, { + status: response.status, + statusText: response.statusText, + headers, + }), + usage, + } + } + } + + // Handle SSE stream + try { + const text = await response.text() + const transformed = transformStreamingPayload(text) + + return { + response: new Response(transformed, { + status: response.status, + statusText: response.statusText, + headers, + }), + usage, + } + } catch { + // If reading fails, return original response + return { response, usage } + } +} + +/** + * Check if response is a streaming SSE response + * + * @param response - Fetch Response object + * @returns True if response is SSE stream + */ +export function isStreamingResponse(response: Response): boolean { + const contentType = response.headers.get("content-type") ?? "" + return contentType.includes("text/event-stream") +} + +/** + * Extract usage from SSE payload text + * + * Looks for usageMetadata in SSE events: + * data: { "usageMetadata": { ... } } + * + * @param payload - SSE payload text + * @returns Usage if found + */ +export function extractUsageFromSsePayload(payload: string): AntigravityUsage | undefined { + const lines = payload.split("\n") + + for (const line of lines) { + if (!line.startsWith("data:")) { + continue + } + + const json = line.slice(5).trim() + if (!json || json === "[DONE]") { + continue + } + + try { + const parsed = JSON.parse(json) as Record + + // Check for usageMetadata at top level + if (parsed.usageMetadata && typeof parsed.usageMetadata === "object") { + const meta = parsed.usageMetadata as Record + return { + prompt_tokens: typeof meta.promptTokenCount === "number" ? meta.promptTokenCount : 0, + completion_tokens: + typeof meta.candidatesTokenCount === "number" ? meta.candidatesTokenCount : 0, + total_tokens: typeof meta.totalTokenCount === "number" ? meta.totalTokenCount : 0, + } + } + + // Check for usage in response wrapper + if (parsed.response && typeof parsed.response === "object") { + const resp = parsed.response as Record + if (resp.usageMetadata && typeof resp.usageMetadata === "object") { + const meta = resp.usageMetadata as Record + return { + prompt_tokens: typeof meta.promptTokenCount === "number" ? meta.promptTokenCount : 0, + completion_tokens: + typeof meta.candidatesTokenCount === "number" ? meta.candidatesTokenCount : 0, + total_tokens: typeof meta.totalTokenCount === "number" ? meta.totalTokenCount : 0, + } + } + } + + // Check for standard OpenAI-style usage + if (parsed.usage && typeof parsed.usage === "object") { + const u = parsed.usage as Record + return { + prompt_tokens: typeof u.prompt_tokens === "number" ? u.prompt_tokens : 0, + completion_tokens: typeof u.completion_tokens === "number" ? u.completion_tokens : 0, + total_tokens: typeof u.total_tokens === "number" ? u.total_tokens : 0, + } + } + } catch { + // Continue to next line if parsing fails + } + } + + return undefined +} diff --git a/src/auth/antigravity/thinking.ts b/src/auth/antigravity/thinking.ts index 37beff3..1cc2b92 100644 --- a/src/auth/antigravity/thinking.ts +++ b/src/auth/antigravity/thinking.ts @@ -1 +1,571 @@ -// Antigravity thinking block handler - to be implemented in Task 10 +/** + * Antigravity Thinking Block Handler (Gemini only) + * + * Handles extraction and transformation of thinking/reasoning blocks + * from Gemini responses. Thinking blocks contain the model's internal + * reasoning process, available in `-high` model variants. + * + * Key responsibilities: + * - Extract thinking blocks from Gemini response format + * - Detect thinking-capable model variants (`-high` suffix) + * - Format thinking blocks for OpenAI-compatible output + * + * Note: This is Gemini-only. Claude models are NOT handled by Antigravity. + */ + +/** + * Represents a single thinking/reasoning block extracted from Gemini response + */ +export interface ThinkingBlock { + /** The thinking/reasoning text content */ + text: string + /** Optional signature for signed thinking blocks (required for multi-turn) */ + signature?: string + /** Index of the thinking block in sequence */ + index?: number +} + +/** + * Raw part structure from Gemini response candidates + */ +export interface GeminiPart { + /** Text content of the part */ + text?: string + /** Whether this part is a thinking/reasoning block */ + thought?: boolean + /** Signature for signed thinking blocks */ + thoughtSignature?: string + /** Type field for Anthropic-style format */ + type?: string + /** Signature field for Anthropic-style format */ + signature?: string +} + +/** + * Gemini response candidate structure + */ +export interface GeminiCandidate { + /** Content containing parts */ + content?: { + /** Role of the content (e.g., "model", "assistant") */ + role?: string + /** Array of content parts */ + parts?: GeminiPart[] + } + /** Index of the candidate */ + index?: number +} + +/** + * Gemini response structure for thinking block extraction + */ +export interface GeminiResponse { + /** Response ID */ + id?: string + /** Array of response candidates */ + candidates?: GeminiCandidate[] + /** Direct content (some responses use this instead of candidates) */ + content?: Array<{ + type?: string + text?: string + signature?: string + }> + /** Model used for response */ + model?: string +} + +/** + * Result of thinking block extraction + */ +export interface ThinkingExtractionResult { + /** Extracted thinking blocks */ + thinkingBlocks: ThinkingBlock[] + /** Combined thinking text for convenience */ + combinedThinking: string + /** Whether any thinking blocks were found */ + hasThinking: boolean +} + +/** + * Default thinking budget in tokens for thinking-enabled models + */ +export const DEFAULT_THINKING_BUDGET = 16000 + +/** + * Check if a model variant should include thinking blocks + * + * Returns true for model variants with `-high` suffix, which have + * extended thinking capability enabled. + * + * Examples: + * - `gemini-3-pro-high` → true + * - `gemini-2.5-pro-high` → true + * - `gemini-3-pro-preview` → false + * - `gemini-2.5-pro` → false + * + * @param model - Model identifier string + * @returns True if model should include thinking blocks + */ +export function shouldIncludeThinking(model: string): boolean { + if (!model || typeof model !== "string") { + return false + } + + const lowerModel = model.toLowerCase() + + // Check for -high suffix (primary indicator of thinking capability) + if (lowerModel.endsWith("-high")) { + return true + } + + // Also check for explicit thinking in model name + if (lowerModel.includes("thinking")) { + return true + } + + return false +} + +/** + * Check if a model is thinking-capable (broader check) + * + * This is a broader check than shouldIncludeThinking - it detects models + * that have thinking capability, even if not explicitly requesting thinking output. + * + * @param model - Model identifier string + * @returns True if model supports thinking/reasoning + */ +export function isThinkingCapableModel(model: string): boolean { + if (!model || typeof model !== "string") { + return false + } + + const lowerModel = model.toLowerCase() + + return ( + lowerModel.includes("thinking") || + lowerModel.includes("gemini-3") || + lowerModel.endsWith("-high") + ) +} + +/** + * Check if a part is a thinking/reasoning block + * + * Detects both Gemini-style (thought: true) and Anthropic-style + * (type: "thinking" or type: "reasoning") formats. + * + * @param part - Content part to check + * @returns True if part is a thinking block + */ +function isThinkingPart(part: GeminiPart): boolean { + // Gemini-style: thought flag + if (part.thought === true) { + return true + } + + // Anthropic-style: type field + if (part.type === "thinking" || part.type === "reasoning") { + return true + } + + return false +} + +/** + * Check if a thinking part has a valid signature + * + * Signatures are required for multi-turn conversations with Claude models. + * Gemini uses `thoughtSignature`, Anthropic uses `signature`. + * + * @param part - Thinking part to check + * @returns True if part has valid signature + */ +function hasValidSignature(part: GeminiPart): boolean { + // Gemini-style signature + if (part.thought === true && part.thoughtSignature) { + return true + } + + // Anthropic-style signature + if ((part.type === "thinking" || part.type === "reasoning") && part.signature) { + return true + } + + return false +} + +/** + * Extract thinking blocks from a Gemini response + * + * Parses the response structure to identify and extract all thinking/reasoning + * content. Supports both Gemini-style (thought: true) and Anthropic-style + * (type: "thinking") formats. + * + * @param response - Gemini response object + * @returns Extraction result with thinking blocks and metadata + */ +export function extractThinkingBlocks(response: GeminiResponse): ThinkingExtractionResult { + const thinkingBlocks: ThinkingBlock[] = [] + + // Handle candidates array (standard Gemini format) + if (response.candidates && Array.isArray(response.candidates)) { + for (const candidate of response.candidates) { + const parts = candidate.content?.parts + if (!parts || !Array.isArray(parts)) { + continue + } + + for (let i = 0; i < parts.length; i++) { + const part = parts[i] + if (!part || typeof part !== "object") { + continue + } + + if (isThinkingPart(part)) { + const block: ThinkingBlock = { + text: part.text || "", + index: thinkingBlocks.length, + } + + // Extract signature if present + if (part.thought === true && part.thoughtSignature) { + block.signature = part.thoughtSignature + } else if (part.signature) { + block.signature = part.signature + } + + thinkingBlocks.push(block) + } + } + } + } + + // Handle direct content array (Anthropic-style response) + if (response.content && Array.isArray(response.content)) { + for (let i = 0; i < response.content.length; i++) { + const item = response.content[i] + if (!item || typeof item !== "object") { + continue + } + + if (item.type === "thinking" || item.type === "reasoning") { + thinkingBlocks.push({ + text: item.text || "", + signature: item.signature, + index: thinkingBlocks.length, + }) + } + } + } + + // Combine all thinking text + const combinedThinking = thinkingBlocks.map((b) => b.text).join("\n\n") + + return { + thinkingBlocks, + combinedThinking, + hasThinking: thinkingBlocks.length > 0, + } +} + +/** + * Format thinking blocks for OpenAI-compatible output + * + * Converts Gemini thinking block format to OpenAI's expected structure. + * OpenAI expects thinking content as special message blocks or annotations. + * + * Output format: + * ``` + * [ + * { type: "reasoning", text: "thinking content...", signature?: "..." }, + * ... + * ] + * ``` + * + * @param thinking - Array of thinking blocks to format + * @returns OpenAI-compatible formatted array + */ +export function formatThinkingForOpenAI( + thinking: ThinkingBlock[], +): Array<{ type: "reasoning"; text: string; signature?: string }> { + if (!thinking || !Array.isArray(thinking) || thinking.length === 0) { + return [] + } + + return thinking.map((block) => { + const formatted: { type: "reasoning"; text: string; signature?: string } = { + type: "reasoning", + text: block.text || "", + } + + if (block.signature) { + formatted.signature = block.signature + } + + return formatted + }) +} + +/** + * Transform thinking parts in a candidate to OpenAI format + * + * Modifies candidate content parts to use OpenAI-style reasoning format + * while preserving the rest of the response structure. + * + * @param candidate - Gemini candidate to transform + * @returns Transformed candidate with reasoning-formatted thinking + */ +export function transformCandidateThinking(candidate: GeminiCandidate): GeminiCandidate { + if (!candidate || typeof candidate !== "object") { + return candidate + } + + const content = candidate.content + if (!content || typeof content !== "object" || !Array.isArray(content.parts)) { + return candidate + } + + const thinkingTexts: string[] = [] + const transformedParts = content.parts.map((part) => { + if (part && typeof part === "object" && part.thought === true) { + thinkingTexts.push(part.text || "") + // Transform to reasoning format + return { + ...part, + type: "reasoning" as const, + thought: undefined, // Remove Gemini-specific field + } + } + return part + }) + + const result: GeminiCandidate & { reasoning_content?: string } = { + ...candidate, + content: { ...content, parts: transformedParts }, + } + + // Add combined reasoning content for convenience + if (thinkingTexts.length > 0) { + result.reasoning_content = thinkingTexts.join("\n\n") + } + + return result +} + +/** + * Transform Anthropic-style thinking blocks to reasoning format + * + * Converts `type: "thinking"` blocks to `type: "reasoning"` for consistency. + * + * @param content - Array of content blocks + * @returns Transformed content array + */ +export function transformAnthropicThinking( + content: Array<{ type?: string; text?: string; signature?: string }>, +): Array<{ type?: string; text?: string; signature?: string }> { + if (!content || !Array.isArray(content)) { + return content + } + + return content.map((block) => { + if (block && typeof block === "object" && block.type === "thinking") { + return { + type: "reasoning", + text: block.text || "", + ...(block.signature ? { signature: block.signature } : {}), + } + } + return block + }) +} + +/** + * Filter out unsigned thinking blocks + * + * Claude API requires signed thinking blocks for multi-turn conversations. + * This function removes thinking blocks without valid signatures. + * + * @param parts - Array of content parts + * @returns Filtered array without unsigned thinking blocks + */ +export function filterUnsignedThinkingBlocks(parts: GeminiPart[]): GeminiPart[] { + if (!parts || !Array.isArray(parts)) { + return parts + } + + return parts.filter((part) => { + if (!part || typeof part !== "object") { + return true + } + + // If it's a thinking part, only keep it if signed + if (isThinkingPart(part)) { + return hasValidSignature(part) + } + + // Keep all non-thinking parts + return true + }) +} + +/** + * Transform entire response thinking parts + * + * Main transformation function that handles both Gemini-style and + * Anthropic-style thinking blocks in a response. + * + * @param response - Response object to transform + * @returns Transformed response with standardized reasoning format + */ +export function transformResponseThinking(response: GeminiResponse): GeminiResponse { + if (!response || typeof response !== "object") { + return response + } + + const result: GeminiResponse = { ...response } + + // Transform candidates (Gemini-style) + if (Array.isArray(result.candidates)) { + result.candidates = result.candidates.map(transformCandidateThinking) + } + + // Transform direct content (Anthropic-style) + if (Array.isArray(result.content)) { + result.content = transformAnthropicThinking(result.content) + } + + return result +} + +/** + * Thinking configuration for requests + */ +export interface ThinkingConfig { + /** Token budget for thinking/reasoning */ + thinkingBudget?: number + /** Whether to include thoughts in response */ + includeThoughts?: boolean +} + +/** + * Normalize thinking configuration + * + * Ensures thinkingConfig is valid: includeThoughts only allowed when budget > 0. + * + * @param config - Raw thinking configuration + * @returns Normalized configuration or undefined + */ +export function normalizeThinkingConfig(config: unknown): ThinkingConfig | undefined { + if (!config || typeof config !== "object") { + return undefined + } + + const record = config as Record + const budgetRaw = record.thinkingBudget ?? record.thinking_budget + const includeRaw = record.includeThoughts ?? record.include_thoughts + + const thinkingBudget = + typeof budgetRaw === "number" && Number.isFinite(budgetRaw) ? budgetRaw : undefined + const includeThoughts = typeof includeRaw === "boolean" ? includeRaw : undefined + + const enableThinking = thinkingBudget !== undefined && thinkingBudget > 0 + const finalInclude = enableThinking ? (includeThoughts ?? false) : false + + // Return undefined if no meaningful config + if ( + !enableThinking && + finalInclude === false && + thinkingBudget === undefined && + includeThoughts === undefined + ) { + return undefined + } + + const normalized: ThinkingConfig = {} + if (thinkingBudget !== undefined) { + normalized.thinkingBudget = thinkingBudget + } + if (finalInclude !== undefined) { + normalized.includeThoughts = finalInclude + } + return normalized +} + +/** + * Extract thinking configuration from request payload + * + * Supports both Gemini-style thinkingConfig and Anthropic-style thinking options. + * + * @param requestPayload - Request body + * @param generationConfig - Generation config from request + * @param extraBody - Extra body options + * @returns Extracted thinking configuration or undefined + */ +export function extractThinkingConfig( + requestPayload: Record, + generationConfig?: Record, + extraBody?: Record, +): ThinkingConfig | undefined { + // Check for explicit thinkingConfig + const thinkingConfig = + generationConfig?.thinkingConfig ?? extraBody?.thinkingConfig ?? requestPayload.thinkingConfig + + if (thinkingConfig && typeof thinkingConfig === "object") { + const config = thinkingConfig as Record + return { + includeThoughts: Boolean(config.includeThoughts), + thinkingBudget: + typeof config.thinkingBudget === "number" ? config.thinkingBudget : DEFAULT_THINKING_BUDGET, + } + } + + // Convert Anthropic-style "thinking" option: { type: "enabled", budgetTokens: N } + const anthropicThinking = extraBody?.thinking ?? requestPayload.thinking + if (anthropicThinking && typeof anthropicThinking === "object") { + const thinking = anthropicThinking as Record + if (thinking.type === "enabled" || thinking.budgetTokens) { + return { + includeThoughts: true, + thinkingBudget: + typeof thinking.budgetTokens === "number" + ? thinking.budgetTokens + : DEFAULT_THINKING_BUDGET, + } + } + } + + return undefined +} + +/** + * Resolve final thinking configuration based on model and context + * + * Handles special cases like Claude models requiring signed thinking blocks + * for multi-turn conversations. + * + * @param userConfig - User-provided thinking configuration + * @param isThinkingModel - Whether model supports thinking + * @param isClaudeModel - Whether model is Claude (not used in Antigravity, but kept for compatibility) + * @param hasAssistantHistory - Whether conversation has assistant history + * @returns Final thinking configuration + */ +export function resolveThinkingConfig( + userConfig: ThinkingConfig | undefined, + isThinkingModel: boolean, + isClaudeModel: boolean, + hasAssistantHistory: boolean, +): ThinkingConfig | undefined { + // Claude models with history need signed thinking blocks + // Since we can't guarantee signatures, disable thinking + if (isClaudeModel && hasAssistantHistory) { + return { includeThoughts: false, thinkingBudget: 0 } + } + + // Enable thinking by default for thinking-capable models + if (isThinkingModel && !userConfig) { + return { includeThoughts: true, thinkingBudget: DEFAULT_THINKING_BUDGET } + } + + return userConfig +} diff --git a/src/auth/antigravity/tools.ts b/src/auth/antigravity/tools.ts index 7bd8f1b..4470433 100644 --- a/src/auth/antigravity/tools.ts +++ b/src/auth/antigravity/tools.ts @@ -1 +1,244 @@ -// Antigravity tool normalization - to be implemented in Task 9 +/** + * Antigravity Tool Normalization + * Converts tools between OpenAI and Gemini formats. + * + * OpenAI format: + * { "type": "function", "function": { "name": "x", "description": "...", "parameters": {...} } } + * + * Gemini format: + * { "functionDeclarations": [{ "name": "x", "description": "...", "parameters": {...} }] } + * + * Note: This is for Gemini models ONLY. Claude models are not supported via Antigravity. + */ + +/** + * OpenAI function tool format + */ +export interface OpenAITool { + type: string + function?: { + name: string + description?: string + parameters?: Record + } +} + +/** + * Gemini function declaration format + */ +export interface GeminiFunctionDeclaration { + name: string + description?: string + parameters?: Record +} + +/** + * Gemini tools format (array of functionDeclarations) + */ +export interface GeminiTools { + functionDeclarations: GeminiFunctionDeclaration[] +} + +/** + * OpenAI tool call in response + */ +export interface OpenAIToolCall { + id: string + type: "function" + function: { + name: string + arguments: string + } +} + +/** + * Gemini function call in response + */ +export interface GeminiFunctionCall { + name: string + args: Record +} + +/** + * Gemini function response format + */ +export interface GeminiFunctionResponse { + name: string + response: Record +} + +/** + * Gemini tool result containing function calls + */ +export interface GeminiToolResult { + functionCall?: GeminiFunctionCall + functionResponse?: GeminiFunctionResponse +} + +/** + * Normalize OpenAI-format tools to Gemini format. + * Converts an array of OpenAI tools to Gemini's functionDeclarations format. + * + * - Handles `function` type tools with name, description, parameters + * - Logs warning for unsupported tool types (does NOT silently drop them) + * - Creates a single object with functionDeclarations array + * + * @param tools - Array of OpenAI-format tools + * @returns Gemini-format tools object with functionDeclarations, or undefined if no valid tools + */ +export function normalizeToolsForGemini( + tools: OpenAITool[] +): GeminiTools | undefined { + if (!tools || tools.length === 0) { + return undefined + } + + const functionDeclarations: GeminiFunctionDeclaration[] = [] + + for (const tool of tools) { + // Handle function type tools + if (tool.type === "function" && tool.function) { + const declaration: GeminiFunctionDeclaration = { + name: tool.function.name, + } + + // Include description if present + if (tool.function.description) { + declaration.description = tool.function.description + } + + // Include parameters if present, default to empty object schema + if (tool.function.parameters) { + declaration.parameters = tool.function.parameters + } else { + // Gemini requires parameters field, use empty object as default + declaration.parameters = { type: "object", properties: {} } + } + + functionDeclarations.push(declaration) + } else { + // Log warning for unsupported tool types + console.warn( + `[antigravity-tools] Unsupported tool type: "${tool.type}". ` + + `Only "function" type tools are supported for Gemini. Tool will be skipped.` + ) + } + } + + // Return undefined if no valid function declarations + if (functionDeclarations.length === 0) { + return undefined + } + + return { functionDeclarations } +} + +/** + * Convert Gemini tool results (functionCall) back to OpenAI tool_call format. + * Handles both functionCall (request) and functionResponse (result) formats. + * + * Gemini functionCall format: + * { "name": "tool_name", "args": { ... } } + * + * OpenAI tool_call format: + * { "id": "call_xxx", "type": "function", "function": { "name": "tool_name", "arguments": "..." } } + * + * @param results - Array of Gemini tool results containing functionCall or functionResponse + * @returns Array of OpenAI-format tool calls + */ +export function normalizeToolResultsFromGemini( + results: GeminiToolResult[] +): OpenAIToolCall[] { + if (!results || results.length === 0) { + return [] + } + + const toolCalls: OpenAIToolCall[] = [] + let callCounter = 0 + + for (const result of results) { + // Handle functionCall (tool invocation from model) + if (result.functionCall) { + callCounter++ + const toolCall: OpenAIToolCall = { + id: `call_${Date.now()}_${callCounter}`, + type: "function", + function: { + name: result.functionCall.name, + arguments: JSON.stringify(result.functionCall.args ?? {}), + }, + } + toolCalls.push(toolCall) + } + } + + return toolCalls +} + +/** + * Convert a single Gemini functionCall to OpenAI tool_call format. + * Useful for streaming responses where each chunk may contain a function call. + * + * @param functionCall - Gemini function call + * @param id - Optional tool call ID (generates one if not provided) + * @returns OpenAI-format tool call + */ +export function convertFunctionCallToToolCall( + functionCall: GeminiFunctionCall, + id?: string +): OpenAIToolCall { + return { + id: id ?? `call_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`, + type: "function", + function: { + name: functionCall.name, + arguments: JSON.stringify(functionCall.args ?? {}), + }, + } +} + +/** + * Check if a tool array contains any function-type tools. + * + * @param tools - Array of OpenAI-format tools + * @returns true if there are function tools to normalize + */ +export function hasFunctionTools(tools: OpenAITool[]): boolean { + if (!tools || tools.length === 0) { + return false + } + + return tools.some((tool) => tool.type === "function" && tool.function) +} + +/** + * Extract function declarations from already-normalized Gemini tools. + * Useful when tools may already be in Gemini format. + * + * @param tools - Tools that may be in Gemini or OpenAI format + * @returns Array of function declarations + */ +export function extractFunctionDeclarations( + tools: unknown +): GeminiFunctionDeclaration[] { + if (!tools || typeof tools !== "object") { + return [] + } + + // Check if already in Gemini format + const geminiTools = tools as Record + if ( + Array.isArray(geminiTools.functionDeclarations) && + geminiTools.functionDeclarations.length > 0 + ) { + return geminiTools.functionDeclarations as GeminiFunctionDeclaration[] + } + + // Check if it's an array of OpenAI tools + if (Array.isArray(tools)) { + const normalized = normalizeToolsForGemini(tools as OpenAITool[]) + return normalized?.functionDeclarations ?? [] + } + + return [] +}