diff --git a/packages/ai/scripts/generate-models.ts b/packages/ai/scripts/generate-models.ts index d3b9c8f1..eed8b04f 100644 --- a/packages/ai/scripts/generate-models.ts +++ b/packages/ai/scripts/generate-models.ts @@ -482,6 +482,97 @@ async function generateModels() { }); } + // Add Google Cloud Code Assist models (Gemini CLI / Antigravity) + // These use OAuth authentication via Google account, cost is $0 (uses account quota) + const CLOUD_CODE_ASSIST_ENDPOINT = "https://cloudcode-pa.googleapis.com"; + const cloudCodeAssistModels: Model<"google-cloud-code-assist">[] = [ + { + id: "gemini-3-pro-high", + name: "Gemini 3 Pro High (Cloud Code Assist)", + api: "google-cloud-code-assist", + provider: "google-cloud-code-assist", + baseUrl: CLOUD_CODE_ASSIST_ENDPOINT, + reasoning: true, + input: ["text", "image"], + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + contextWindow: 1048576, + maxTokens: 65535, + }, + { + id: "gemini-3-pro-low", + name: "Gemini 3 Pro Low (Cloud Code Assist)", + api: "google-cloud-code-assist", + provider: "google-cloud-code-assist", + baseUrl: CLOUD_CODE_ASSIST_ENDPOINT, + reasoning: true, + input: ["text", "image"], + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + contextWindow: 1048576, + maxTokens: 65535, + }, + { + id: "gemini-3-flash", + name: "Gemini 3 Flash (Cloud Code Assist)", + api: "google-cloud-code-assist", + provider: "google-cloud-code-assist", + baseUrl: CLOUD_CODE_ASSIST_ENDPOINT, + reasoning: false, + input: ["text", "image"], + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + contextWindow: 1048576, + maxTokens: 65536, + }, + { + id: "claude-sonnet-4-5", + name: "Claude Sonnet 4.5 (Cloud Code Assist)", + api: "google-cloud-code-assist", + provider: "google-cloud-code-assist", + baseUrl: CLOUD_CODE_ASSIST_ENDPOINT, + reasoning: false, + input: ["text", "image"], + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + contextWindow: 200000, + maxTokens: 64000, + }, + { + id: "claude-sonnet-4-5-thinking", + name: "Claude Sonnet 4.5 Thinking (Cloud Code Assist)", + api: "google-cloud-code-assist", + provider: "google-cloud-code-assist", + baseUrl: CLOUD_CODE_ASSIST_ENDPOINT, + reasoning: true, + input: ["text", "image"], + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + contextWindow: 200000, + maxTokens: 64000, + }, + { + id: "claude-opus-4-5-thinking", + name: "Claude Opus 4.5 Thinking (Cloud Code Assist)", + api: "google-cloud-code-assist", + provider: "google-cloud-code-assist", + baseUrl: CLOUD_CODE_ASSIST_ENDPOINT, + reasoning: true, + input: ["text", "image"], + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + contextWindow: 200000, + maxTokens: 64000, + }, + { + id: "gpt-oss-120b-medium", + name: "GPT-OSS 120B Medium (Cloud Code Assist)", + api: "google-cloud-code-assist", + provider: "google-cloud-code-assist", + baseUrl: CLOUD_CODE_ASSIST_ENDPOINT, + reasoning: false, + input: ["text", "image"], + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + contextWindow: 131072, + maxTokens: 32768, + }, + ]; + allModels.push(...cloudCodeAssistModels); + // Group by provider and deduplicate by model ID const providers: Record>> = {}; for (const model of allModels) { diff --git a/packages/ai/src/index.ts b/packages/ai/src/index.ts index f9ed0753..4e960daa 100644 --- a/packages/ai/src/index.ts +++ b/packages/ai/src/index.ts @@ -2,6 +2,7 @@ export * from "./agent/index.js"; export * from "./models.js"; export * from "./providers/anthropic.js"; export * from "./providers/google.js"; +export * from "./providers/google-cloud-code-assist.js"; export * from "./providers/openai-completions.js"; export * from "./providers/openai-responses.js"; export * from "./stream.js"; diff --git a/packages/ai/src/models.generated.ts b/packages/ai/src/models.generated.ts index e682aa54..7acfe5a0 100644 --- a/packages/ai/src/models.generated.ts +++ b/packages/ai/src/models.generated.ts @@ -6819,4 +6819,125 @@ export const MODELS = { maxTokens: 30000, } satisfies Model<"openai-completions">, }, + "google-cloud-code-assist": { + "gemini-3-pro-high": { + id: "gemini-3-pro-high", + name: "Gemini 3 Pro High (Cloud Code Assist)", + api: "google-cloud-code-assist", + provider: "google-cloud-code-assist", + baseUrl: "https://cloudcode-pa.googleapis.com", + reasoning: true, + input: ["text", "image"], + cost: { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 1048576, + maxTokens: 65535, + } satisfies Model<"google-cloud-code-assist">, + "gemini-3-pro-low": { + id: "gemini-3-pro-low", + name: "Gemini 3 Pro Low (Cloud Code Assist)", + api: "google-cloud-code-assist", + provider: "google-cloud-code-assist", + baseUrl: "https://cloudcode-pa.googleapis.com", + reasoning: true, + input: ["text", "image"], + cost: { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 1048576, + maxTokens: 65535, + } satisfies Model<"google-cloud-code-assist">, + "gemini-3-flash": { + id: "gemini-3-flash", + name: "Gemini 3 Flash (Cloud Code Assist)", + api: "google-cloud-code-assist", + provider: "google-cloud-code-assist", + baseUrl: "https://cloudcode-pa.googleapis.com", + reasoning: false, + input: ["text", "image"], + cost: { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 1048576, + maxTokens: 65536, + } satisfies Model<"google-cloud-code-assist">, + "claude-sonnet-4-5": { + id: "claude-sonnet-4-5", + name: "Claude Sonnet 4.5 (Cloud Code Assist)", + api: "google-cloud-code-assist", + provider: "google-cloud-code-assist", + baseUrl: "https://cloudcode-pa.googleapis.com", + reasoning: false, + input: ["text", "image"], + cost: { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 200000, + maxTokens: 64000, + } satisfies Model<"google-cloud-code-assist">, + "claude-sonnet-4-5-thinking": { + id: "claude-sonnet-4-5-thinking", + name: "Claude Sonnet 4.5 Thinking (Cloud Code Assist)", + api: "google-cloud-code-assist", + provider: "google-cloud-code-assist", + baseUrl: "https://cloudcode-pa.googleapis.com", + reasoning: true, + input: ["text", "image"], + cost: { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 200000, + maxTokens: 64000, + } satisfies Model<"google-cloud-code-assist">, + "claude-opus-4-5-thinking": { + id: "claude-opus-4-5-thinking", + name: "Claude Opus 4.5 Thinking (Cloud Code Assist)", + api: "google-cloud-code-assist", + provider: "google-cloud-code-assist", + baseUrl: "https://cloudcode-pa.googleapis.com", + reasoning: true, + input: ["text", "image"], + cost: { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 200000, + maxTokens: 64000, + } satisfies Model<"google-cloud-code-assist">, + "gpt-oss-120b-medium": { + id: "gpt-oss-120b-medium", + name: "GPT-OSS 120B Medium (Cloud Code Assist)", + api: "google-cloud-code-assist", + provider: "google-cloud-code-assist", + baseUrl: "https://cloudcode-pa.googleapis.com", + reasoning: false, + input: ["text", "image"], + cost: { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 131072, + maxTokens: 32768, + } satisfies Model<"google-cloud-code-assist">, + }, } as const; diff --git a/packages/ai/src/providers/google-cloud-code-assist.ts b/packages/ai/src/providers/google-cloud-code-assist.ts new file mode 100644 index 00000000..dd1de439 --- /dev/null +++ b/packages/ai/src/providers/google-cloud-code-assist.ts @@ -0,0 +1,429 @@ +/** + * Google Cloud Code Assist provider for Gemini CLI / Antigravity authentication. + * Uses the Cloud Code Assist API endpoint to access Gemini and Claude models. + */ + +import type { Content, ThinkingConfig } from "@google/genai"; +import { calculateCost } from "../models.js"; +import type { + Api, + AssistantMessage, + Context, + Model, + StreamFunction, + StreamOptions, + TextContent, + ThinkingContent, + ToolCall, +} from "../types.js"; +import { AssistantMessageEventStream } from "../utils/event-stream.js"; +import { sanitizeSurrogates } from "../utils/sanitize-unicode.js"; +import { convertMessages, convertTools, mapStopReasonString, mapToolChoice } from "./google-shared.js"; + +export interface GoogleCloudCodeAssistOptions extends StreamOptions { + toolChoice?: "auto" | "none" | "any"; + thinking?: { + enabled: boolean; + budgetTokens?: number; + }; + projectId?: string; +} + +const ENDPOINT = "https://cloudcode-pa.googleapis.com"; +const HEADERS = { + "User-Agent": "google-api-nodejs-client/9.15.1", + "X-Goog-Api-Client": "gl-node/22.17.0", + "Client-Metadata": "ideType=IDE_UNSPECIFIED,platform=PLATFORM_UNSPECIFIED,pluginType=GEMINI", +}; + +// Counter for generating unique tool call IDs +let toolCallCounter = 0; + +interface CloudCodeAssistRequest { + project: string; + model: string; + request: { + contents: Content[]; + systemInstruction?: { parts: { text: string }[] }; + generationConfig?: { + maxOutputTokens?: number; + temperature?: number; + thinkingConfig?: ThinkingConfig; + }; + tools?: ReturnType; + toolConfig?: { + functionCallingConfig: { + mode: ReturnType; + }; + }; + }; + 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; + }; + traceId?: string; +} + +export const streamGoogleCloudCodeAssist: StreamFunction<"google-cloud-code-assist"> = ( + model: Model<"google-cloud-code-assist">, + context: Context, + options?: GoogleCloudCodeAssistOptions, +): AssistantMessageEventStream => { + const stream = new AssistantMessageEventStream(); + + (async () => { + const output: AssistantMessage = { + role: "assistant", + content: [], + api: "google-cloud-code-assist" as 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 apiKey = options?.apiKey; + if (!apiKey) { + throw new Error("Google Cloud Code Assist requires an OAuth access token"); + } + + const projectId = options?.projectId; + if (!projectId) { + throw new Error("Google Cloud Code Assist requires a project ID"); + } + + const requestBody = buildRequest(model, context, projectId, options); + const url = `${ENDPOINT}/v1internal:streamGenerateContent?alt=sse`; + + const response = await fetch(url, { + method: "POST", + headers: { + Authorization: `Bearer ${apiKey}`, + "Content-Type": "application/json", + Accept: "text/event-stream", + ...HEADERS, + }, + body: JSON.stringify(requestBody), + signal: options?.signal, + }); + + if (!response.ok) { + const errorText = await response.text(); + throw new Error(`Cloud Code Assist API error (${response.status}): ${errorText}`); + } + + if (!response.body) { + throw new Error("No response body"); + } + + stream.push({ type: "start", partial: output }); + + let currentBlock: TextContent | ThinkingContent | null = null; + const blocks = output.content; + const blockIndex = () => blocks.length - 1; + + // Read SSE stream + const reader = response.body.getReader(); + const decoder = new TextDecoder(); + let buffer = ""; + + while (true) { + 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; + } + + // Unwrap the response + const responseData = chunk.response; + if (!responseData) continue; + + const candidate = responseData.candidates?.[0]; + if (candidate?.content?.parts) { + for (const part of candidate.content.parts) { + if (part.text !== undefined) { + const isThinking = part.thought === true; + if ( + !currentBlock || + (isThinking && currentBlock.type !== "thinking") || + (!isThinking && currentBlock.type !== "text") + ) { + if (currentBlock) { + if (currentBlock.type === "text") { + stream.push({ + type: "text_end", + contentIndex: blocks.length - 1, + 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); + stream.push({ type: "thinking_start", contentIndex: blockIndex(), partial: output }); + } else { + currentBlock = { type: "text", text: "" }; + output.content.push(currentBlock); + stream.push({ type: "text_start", contentIndex: blockIndex(), partial: output }); + } + } + if (currentBlock.type === "thinking") { + currentBlock.thinking += part.text; + currentBlock.thinkingSignature = part.thoughtSignature; + stream.push({ + type: "thinking_delta", + contentIndex: blockIndex(), + delta: part.text, + partial: output, + }); + } else { + currentBlock.text += part.text; + stream.push({ + type: "text_delta", + contentIndex: blockIndex(), + delta: part.text, + partial: output, + }); + } + } + + if (part.functionCall) { + 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); + 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) { + output.usage = { + input: responseData.usageMetadata.promptTokenCount || 0, + output: + (responseData.usageMetadata.candidatesTokenCount || 0) + + (responseData.usageMetadata.thoughtsTokenCount || 0), + cacheRead: responseData.usageMetadata.cachedContentTokenCount || 0, + cacheWrite: 0, + totalTokens: responseData.usageMetadata.totalTokenCount || 0, + cost: { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + total: 0, + }, + }; + calculateCost(model, output.usage); + } + } + } + + 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 (options?.signal?.aborted) { + throw new Error("Request was aborted"); + } + + if (output.stopReason === "aborted" || output.stopReason === "error") { + throw new Error("An unknown error occurred"); + } + + stream.push({ type: "done", reason: output.stopReason, message: output }); + stream.end(); + } catch (error) { + for (const block of output.content) { + if ("index" in block) { + delete (block as { index?: number }).index; + } + } + 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; +}; + +function buildRequest( + model: Model<"google-cloud-code-assist">, + context: Context, + projectId: string, + options: GoogleCloudCodeAssistOptions = {}, +): 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; + } + + // Thinking config + if (options.thinking?.enabled && model.reasoning) { + generationConfig.thinkingConfig = { + includeThoughts: true, + }; + if (options.thinking.budgetTokens !== undefined) { + generationConfig.thinkingConfig.thinkingBudget = options.thinking.budgetTokens; + } + } + + const request: CloudCodeAssistRequest["request"] = { + contents, + }; + + // System instruction must be object with parts, not plain string + if (context.systemPrompt) { + request.systemInstruction = { + parts: [{ text: sanitizeSurrogates(context.systemPrompt) }], + }; + } + + if (Object.keys(generationConfig).length > 0) { + request.generationConfig = generationConfig; + } + + if (context.tools && context.tools.length > 0) { + request.tools = convertTools(context.tools); + if (options.toolChoice) { + request.toolConfig = { + functionCallingConfig: { + mode: mapToolChoice(options.toolChoice), + }, + }; + } + } + + return { + project: projectId, + model: model.id, + request, + userAgent: "pi-coding-agent", + requestId: `pi-${Date.now()}-${Math.random().toString(36).slice(2, 11)}`, + }; +} diff --git a/packages/ai/src/providers/google-shared.ts b/packages/ai/src/providers/google-shared.ts new file mode 100644 index 00000000..e4038b81 --- /dev/null +++ b/packages/ai/src/providers/google-shared.ts @@ -0,0 +1,203 @@ +/** + * Shared utilities for Google Generative AI and Google Cloud Code Assist providers. + */ + +import { type Content, FinishReason, FunctionCallingConfigMode, type Part, type Schema } from "@google/genai"; +import type { Context, ImageContent, Model, StopReason, TextContent, Tool } from "../types.js"; +import { sanitizeSurrogates } from "../utils/sanitize-unicode.js"; +import { transformMessages } from "./transorm-messages.js"; + +type GoogleApiType = "google-generative-ai" | "google-cloud-code-assist"; + +/** + * Convert internal messages to Gemini Content[] format. + */ +export function convertMessages(model: Model, context: Context): Content[] { + const contents: Content[] = []; + const transformedMessages = transformMessages(context.messages, model); + + for (const msg of transformedMessages) { + if (msg.role === "user") { + if (typeof msg.content === "string") { + contents.push({ + role: "user", + parts: [{ text: sanitizeSurrogates(msg.content) }], + }); + } else { + const parts: Part[] = msg.content.map((item) => { + if (item.type === "text") { + return { text: sanitizeSurrogates(item.text) }; + } else { + return { + inlineData: { + mimeType: item.mimeType, + data: item.data, + }, + }; + } + }); + const filteredParts = !model.input.includes("image") ? parts.filter((p) => p.text !== undefined) : parts; + if (filteredParts.length === 0) continue; + contents.push({ + role: "user", + parts: filteredParts, + }); + } + } else if (msg.role === "assistant") { + const parts: Part[] = []; + + for (const block of msg.content) { + if (block.type === "text") { + parts.push({ text: sanitizeSurrogates(block.text) }); + } else if (block.type === "thinking") { + const thinkingPart: Part = { + thought: true, + thoughtSignature: block.thinkingSignature, + text: sanitizeSurrogates(block.thinking), + }; + parts.push(thinkingPart); + } else if (block.type === "toolCall") { + const part: Part = { + functionCall: { + id: block.id, + name: block.name, + args: block.arguments, + }, + }; + if (block.thoughtSignature) { + part.thoughtSignature = block.thoughtSignature; + } + parts.push(part); + } + } + + if (parts.length === 0) continue; + contents.push({ + role: "model", + parts, + }); + } else if (msg.role === "toolResult") { + // Build parts array with functionResponse and/or images + const parts: Part[] = []; + + // Extract text and image content + const textContent = msg.content.filter((c): c is TextContent => c.type === "text"); + const textResult = textContent.map((c) => c.text).join("\n"); + const imageContent = model.input.includes("image") + ? msg.content.filter((c): c is ImageContent => c.type === "image") + : []; + + // Always add functionResponse with text result (or placeholder if only images) + const hasText = textResult.length > 0; + const hasImages = imageContent.length > 0; + + // Use "output" key for success, "error" key for errors as per SDK documentation + const responseValue = hasText ? sanitizeSurrogates(textResult) : hasImages ? "(see attached image)" : ""; + + parts.push({ + functionResponse: { + id: msg.toolCallId, + name: msg.toolName, + response: msg.isError ? { error: responseValue } : { output: responseValue }, + }, + }); + + // Add any images as inlineData parts + for (const imageBlock of imageContent) { + parts.push({ + inlineData: { + mimeType: imageBlock.mimeType, + data: imageBlock.data, + }, + }); + } + + contents.push({ + role: "user", + parts, + }); + } + } + + return contents; +} + +/** + * Convert tools to Gemini function declarations format. + */ +export function convertTools( + tools: Tool[], +): { functionDeclarations: { name: string; description?: string; parameters: Schema }[] }[] | undefined { + if (tools.length === 0) return undefined; + return [ + { + functionDeclarations: tools.map((tool) => ({ + name: tool.name, + description: tool.description, + parameters: tool.parameters as Schema, + })), + }, + ]; +} + +/** + * Map tool choice string to Gemini FunctionCallingConfigMode. + */ +export function mapToolChoice(choice: string): FunctionCallingConfigMode { + switch (choice) { + case "auto": + return FunctionCallingConfigMode.AUTO; + case "none": + return FunctionCallingConfigMode.NONE; + case "any": + return FunctionCallingConfigMode.ANY; + default: + return FunctionCallingConfigMode.AUTO; + } +} + +/** + * Map Gemini FinishReason to our StopReason. + */ +export function mapStopReason(reason: FinishReason): StopReason { + switch (reason) { + case FinishReason.STOP: + return "stop"; + case FinishReason.MAX_TOKENS: + return "length"; + case FinishReason.BLOCKLIST: + case FinishReason.PROHIBITED_CONTENT: + case FinishReason.SPII: + case FinishReason.SAFETY: + case FinishReason.IMAGE_SAFETY: + case FinishReason.IMAGE_PROHIBITED_CONTENT: + case FinishReason.IMAGE_RECITATION: + case FinishReason.IMAGE_OTHER: + case FinishReason.RECITATION: + case FinishReason.FINISH_REASON_UNSPECIFIED: + case FinishReason.OTHER: + case FinishReason.LANGUAGE: + case FinishReason.MALFORMED_FUNCTION_CALL: + case FinishReason.UNEXPECTED_TOOL_CALL: + case FinishReason.NO_IMAGE: + return "error"; + default: { + const _exhaustive: never = reason; + throw new Error(`Unhandled stop reason: ${_exhaustive}`); + } + } +} + +/** + * Map string finish reason to our StopReason (for raw API responses). + */ +export function mapStopReasonString(reason: string): StopReason { + switch (reason) { + case "STOP": + return "stop"; + case "MAX_TOKENS": + return "length"; + default: + return "error"; + } +} diff --git a/packages/ai/src/providers/google.ts b/packages/ai/src/providers/google.ts index 659dd5b1..25ff0974 100644 --- a/packages/ai/src/providers/google.ts +++ b/packages/ai/src/providers/google.ts @@ -1,12 +1,7 @@ import { - type Content, - FinishReason, - FunctionCallingConfigMode, type GenerateContentConfig, type GenerateContentParameters, GoogleGenAI, - type Part, - type Schema, type ThinkingConfig, type ThinkingLevel, } from "@google/genai"; @@ -15,20 +10,16 @@ import type { Api, AssistantMessage, Context, - ImageContent, Model, - StopReason, StreamFunction, StreamOptions, TextContent, ThinkingContent, - Tool, ToolCall, } from "../types.js"; import { AssistantMessageEventStream } from "../utils/event-stream.js"; import { sanitizeSurrogates } from "../utils/sanitize-unicode.js"; - -import { transformMessages } from "./transorm-messages.js"; +import { convertMessages, convertTools, mapStopReason, mapToolChoice } from "./google-shared.js"; export interface GoogleOptions extends StreamOptions { toolChoice?: "auto" | "none" | "any"; @@ -337,169 +328,3 @@ function buildParams( return params; } -function convertMessages(model: Model<"google-generative-ai">, context: Context): Content[] { - const contents: Content[] = []; - const transformedMessages = transformMessages(context.messages, model); - - for (const msg of transformedMessages) { - if (msg.role === "user") { - if (typeof msg.content === "string") { - contents.push({ - role: "user", - parts: [{ text: sanitizeSurrogates(msg.content) }], - }); - } else { - const parts: Part[] = msg.content.map((item) => { - if (item.type === "text") { - return { text: sanitizeSurrogates(item.text) }; - } else { - return { - inlineData: { - mimeType: item.mimeType, - data: item.data, - }, - }; - } - }); - const filteredParts = !model.input.includes("image") ? parts.filter((p) => p.text !== undefined) : parts; - if (filteredParts.length === 0) continue; - contents.push({ - role: "user", - parts: filteredParts, - }); - } - } else if (msg.role === "assistant") { - const parts: Part[] = []; - - for (const block of msg.content) { - if (block.type === "text") { - parts.push({ text: sanitizeSurrogates(block.text) }); - } else if (block.type === "thinking") { - const thinkingPart: Part = { - thought: true, - thoughtSignature: block.thinkingSignature, - text: sanitizeSurrogates(block.thinking), - }; - parts.push(thinkingPart); - } else if (block.type === "toolCall") { - const part: Part = { - functionCall: { - id: block.id, - name: block.name, - args: block.arguments, - }, - }; - if (block.thoughtSignature) { - part.thoughtSignature = block.thoughtSignature; - } - parts.push(part); - } - } - - if (parts.length === 0) continue; - contents.push({ - role: "model", - parts, - }); - } else if (msg.role === "toolResult") { - // Build parts array with functionResponse and/or images - const parts: Part[] = []; - - // Extract text and image content - const textContent = msg.content.filter((c): c is TextContent => c.type === "text"); - const textResult = textContent.map((c) => c.text).join("\n"); - const imageContent = model.input.includes("image") - ? msg.content.filter((c): c is ImageContent => c.type === "image") - : []; - - // Always add functionResponse with text result (or placeholder if only images) - const hasText = textResult.length > 0; - const hasImages = imageContent.length > 0; - - // Use "output" key for success, "error" key for errors as per SDK documentation - const responseValue = hasText ? sanitizeSurrogates(textResult) : hasImages ? "(see attached image)" : ""; - - parts.push({ - functionResponse: { - id: msg.toolCallId, - name: msg.toolName, - response: msg.isError ? { error: responseValue } : { output: responseValue }, - }, - }); - - // Add any images as inlineData parts - for (const imageBlock of imageContent) { - parts.push({ - inlineData: { - mimeType: imageBlock.mimeType, - data: imageBlock.data, - }, - }); - } - - contents.push({ - role: "user", - parts, - }); - } - } - - return contents; -} - -function convertTools( - tools: Tool[], -): { functionDeclarations: { name: string; description?: string; parameters: Schema }[] }[] | undefined { - if (tools.length === 0) return undefined; - return [ - { - functionDeclarations: tools.map((tool) => ({ - name: tool.name, - description: tool.description, - parameters: tool.parameters as Schema, // TypeBox generates JSON Schema compatible with SDK Schema type - })), - }, - ]; -} - -function mapToolChoice(choice: string): FunctionCallingConfigMode { - switch (choice) { - case "auto": - return FunctionCallingConfigMode.AUTO; - case "none": - return FunctionCallingConfigMode.NONE; - case "any": - return FunctionCallingConfigMode.ANY; - default: - return FunctionCallingConfigMode.AUTO; - } -} - -function mapStopReason(reason: FinishReason): StopReason { - switch (reason) { - case FinishReason.STOP: - return "stop"; - case FinishReason.MAX_TOKENS: - return "length"; - case FinishReason.BLOCKLIST: - case FinishReason.PROHIBITED_CONTENT: - case FinishReason.SPII: - case FinishReason.SAFETY: - case FinishReason.IMAGE_SAFETY: - case FinishReason.IMAGE_PROHIBITED_CONTENT: - case FinishReason.IMAGE_RECITATION: - case FinishReason.IMAGE_OTHER: - case FinishReason.RECITATION: - case FinishReason.FINISH_REASON_UNSPECIFIED: - case FinishReason.OTHER: - case FinishReason.LANGUAGE: - case FinishReason.MALFORMED_FUNCTION_CALL: - case FinishReason.UNEXPECTED_TOOL_CALL: - case FinishReason.NO_IMAGE: - return "error"; - default: { - const _exhaustive: never = reason; - throw new Error(`Unhandled stop reason: ${_exhaustive}`); - } - } -} diff --git a/packages/ai/src/stream.ts b/packages/ai/src/stream.ts index 667779fb..54cde072 100644 --- a/packages/ai/src/stream.ts +++ b/packages/ai/src/stream.ts @@ -2,6 +2,10 @@ import { ThinkingLevel } from "@google/genai"; import { supportsXhigh } from "./models.js"; import { type AnthropicOptions, streamAnthropic } from "./providers/anthropic.js"; import { type GoogleOptions, streamGoogle } from "./providers/google.js"; +import { + type GoogleCloudCodeAssistOptions, + streamGoogleCloudCodeAssist, +} from "./providers/google-cloud-code-assist.js"; import { type OpenAICompletionsOptions, streamOpenAICompletions } from "./providers/openai-completions.js"; import { type OpenAIResponsesOptions, streamOpenAIResponses } from "./providers/openai-responses.js"; import type { @@ -77,6 +81,13 @@ export function stream( case "google-generative-ai": return streamGoogle(model as Model<"google-generative-ai">, context, providerOptions); + case "google-cloud-code-assist": + return streamGoogleCloudCodeAssist( + model as Model<"google-cloud-code-assist">, + context, + providerOptions as GoogleCloudCodeAssistOptions, + ); + default: { // This should never be reached if all Api cases are handled const _exhaustive: never = api; @@ -196,6 +207,29 @@ function mapOptionsForApi( } satisfies GoogleOptions; } + case "google-cloud-code-assist": { + // Cloud Code Assist uses thinking budget tokens like Gemini 2.5 + if (!options?.reasoning) { + return { ...base, thinking: { enabled: false } } satisfies GoogleCloudCodeAssistOptions; + } + + const effort = clampReasoning(options.reasoning)!; + const budgets: Record = { + minimal: 1024, + low: 2048, + medium: 8192, + high: 16384, + }; + + return { + ...base, + thinking: { + enabled: true, + budgetTokens: budgets[effort], + }, + } satisfies GoogleCloudCodeAssistOptions; + } + default: { // Exhaustiveness check const _exhaustive: never = model.api; diff --git a/packages/ai/src/types.ts b/packages/ai/src/types.ts index 83d1d892..b560efc2 100644 --- a/packages/ai/src/types.ts +++ b/packages/ai/src/types.ts @@ -1,18 +1,25 @@ import type { AnthropicOptions } from "./providers/anthropic.js"; import type { GoogleOptions } from "./providers/google.js"; +import type { GoogleCloudCodeAssistOptions } from "./providers/google-cloud-code-assist.js"; import type { OpenAICompletionsOptions } from "./providers/openai-completions.js"; import type { OpenAIResponsesOptions } from "./providers/openai-responses.js"; import type { AssistantMessageEventStream } from "./utils/event-stream.js"; export type { AssistantMessageEventStream } from "./utils/event-stream.js"; -export type Api = "openai-completions" | "openai-responses" | "anthropic-messages" | "google-generative-ai"; +export type Api = + | "openai-completions" + | "openai-responses" + | "anthropic-messages" + | "google-generative-ai" + | "google-cloud-code-assist"; export interface ApiOptionsMap { "anthropic-messages": AnthropicOptions; "openai-completions": OpenAICompletionsOptions; "openai-responses": OpenAIResponsesOptions; "google-generative-ai": GoogleOptions; + "google-cloud-code-assist": GoogleCloudCodeAssistOptions; } // Compile-time exhaustiveness check - this will fail if ApiOptionsMap doesn't have all KnownApi keys diff --git a/packages/tui/src/keys.ts b/packages/tui/src/keys.ts index aca6838a..d82a01ca 100644 --- a/packages/tui/src/keys.ts +++ b/packages/tui/src/keys.ts @@ -259,9 +259,16 @@ export function isCtrlE(data: string): boolean { /** * Check if input matches Ctrl+K (raw byte or Kitty protocol). * Ignores lock key bits. + * Also checks if first byte is 0x0b for compatibility with terminals + * that may send trailing bytes. */ export function isCtrlK(data: string): boolean { - return data === RAW.CTRL_K || data === Keys.CTRL_K || matchesKittySequence(data, CODEPOINTS.k, MODIFIERS.ctrl); + return ( + data === RAW.CTRL_K || + (data.length > 0 && data.charCodeAt(0) === 0x0b) || + data === Keys.CTRL_K || + matchesKittySequence(data, CODEPOINTS.k, MODIFIERS.ctrl) + ); } /** diff --git a/plan.md b/plan.md new file mode 100644 index 00000000..8448a3dd --- /dev/null +++ b/plan.md @@ -0,0 +1,312 @@ +# Google Cloud Code Assist Provider Implementation Plan + +## Overview +Add support for Gemini CLI / Antigravity authentication, which uses Google's Cloud Code Assist API (`cloudcode-pa.googleapis.com`) to access Gemini and Claude models through a unified gateway. + +## References +- Antigravity API Spec: https://github.com/NoeFabris/opencode-antigravity-auth/blob/main/docs/ANTIGRAVITY_API_SPEC.md +- Gemini CLI Auth: https://github.com/jenslys/opencode-gemini-auth +- Antigravity Auth: https://github.com/NoeFabris/opencode-antigravity-auth + +## Key Differences from Standard Google Provider +1. **Endpoint**: `cloudcode-pa.googleapis.com/v1internal:streamGenerateContent?alt=sse` +2. **Auth**: OAuth token (not API key) +3. **Request format**: Wrapped in `{ project, model, request: {...} }` +4. **Response format**: Wrapped in `{ response: {...} }` (needs unwrapping) +5. **Headers**: Requires `User-Agent`, `X-Goog-Api-Client`, `Client-Metadata` + +## OAuth Details +- **Client ID**: `681255809395-oo8ft2oprdrnp9e3aqf6av3hmdib135j.apps.googleusercontent.com` +- **Client Secret**: `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` +- **Redirect URI**: `http://localhost:8085/oauth2callback` +- **Scopes**: `cloud-platform`, `userinfo.email`, `userinfo.profile` +- **Token URL**: `https://oauth2.googleapis.com/token` +- **Auth URL**: `https://accounts.google.com/o/oauth2/v2/auth` + +## Available Models +| Model ID | Type | Context | Output | Reasoning | +|----------|------|---------|--------|-----------| +| gemini-3-pro-high | Gemini | 1M | 64K | Yes | +| gemini-3-pro-low | Gemini | 1M | 64K | Yes | +| gemini-3-flash | Gemini | 1M | 64K | No | +| claude-sonnet-4-5 | Claude | 200K | 64K | No | +| claude-sonnet-4-5-thinking | Claude | 200K | 64K | Yes | +| claude-opus-4-5-thinking | Claude | 200K | 64K | Yes | +| gpt-oss-120b-medium | GPT-OSS | 128K | 32K | No | + +All models support: text, image, pdf input; text output; cost is $0 (uses Google account quota) + +--- + +## Implementation Steps + +### Step 1: Update types.ts +File: `packages/ai/src/types.ts` + +Add new API type: +```typescript +export type Api = "openai-completions" | "openai-responses" | "anthropic-messages" | "google-generative-ai" | "google-cloud-code-assist"; +``` + +### Step 2: Create google-shared.ts +File: `packages/ai/src/providers/google-shared.ts` + +Extract from `google.ts`: +- `convertMessages()` - convert internal messages to Gemini Content[] format +- `convertTools()` - convert tools to Gemini function declarations +- `mapToolChoice()` - map tool choice to Gemini enum +- `mapStopReason()` - map Gemini finish reason to our stop reason +- Shared types and imports + +Make functions generic to work with both `google-generative-ai` and `google-cloud-code-assist` API types. + +### Step 3: Update google.ts +File: `packages/ai/src/providers/google.ts` + +- Import shared functions from `google-shared.ts` +- Remove extracted functions +- Keep: `createClient()`, `buildParams()`, `streamGoogle()` + +### Step 4: Create google-cloud-code-assist.ts +File: `packages/ai/src/providers/google-cloud-code-assist.ts` + +Implement: +```typescript +export interface GoogleCloudCodeAssistOptions extends StreamOptions { + toolChoice?: "auto" | "none" | "any"; + thinking?: { + enabled: boolean; + budgetTokens?: number; + }; + projectId?: string; // Google Cloud project ID +} + +export const streamGoogleCloudCodeAssist: StreamFunction<"google-cloud-code-assist"> = ( + model: Model<"google-cloud-code-assist">, + context: Context, + options?: GoogleCloudCodeAssistOptions, +): AssistantMessageEventStream => { + // Implementation +}; +``` + +Key implementation details: +1. **Build request body**: + ```json + { + "project": "{projectId}", + "model": "{modelId}", + "request": { + "contents": [...], + "systemInstruction": { "parts": [{ "text": "..." }] }, + "generationConfig": { ... }, + "tools": [...] + } + } + ``` + +2. **Headers**: + ``` + Authorization: Bearer {accessToken} + Content-Type: application/json + Accept: text/event-stream + User-Agent: google-api-nodejs-client/9.15.1 + X-Goog-Api-Client: gl-node/22.17.0 + Client-Metadata: ideType=IDE_UNSPECIFIED,platform=PLATFORM_UNSPECIFIED,pluginType=GEMINI + ``` + +3. **Endpoint**: `https://cloudcode-pa.googleapis.com/v1internal:streamGenerateContent?alt=sse` + +4. **Parse SSE response**: + - Each line: `data: {"response": {...}, "traceId": "..."}` + - Extract `response` object, which has same structure as standard Gemini response + - Handle thinking parts with `thought: true` and `thoughtSignature` + +5. **Use shared functions** for message/tool conversion and stop reason mapping + +### Step 5: Update stream.ts +File: `packages/ai/src/stream.ts` + +Add case for new provider: +```typescript +import { streamGoogleCloudCodeAssist } from "./providers/google-cloud-code-assist.js"; + +// In the switch/case or if/else chain: +case "google-cloud-code-assist": + return streamGoogleCloudCodeAssist(model, context, { + ...options, + // map reasoning to thinking config + }); +``` + +### Step 6: Update models.ts +File: `packages/ai/src/models.ts` + +Add to `xhighSupportedModels` if applicable (check if any models support xhigh). + +### Step 7: Add models to generate-models.ts +File: `packages/ai/scripts/generate-models.ts` + +Add hardcoded models: +```typescript +const googleCloudCodeAssistModels: Model<"google-cloud-code-assist">[] = [ + { + id: "gemini-3-pro-high", + provider: "google-cloud-code-assist", + api: "google-cloud-code-assist", + name: "Gemini 3 Pro High", + contextWindow: 1048576, + maxOutputTokens: 65535, + pricing: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + input: ["text", "image", "pdf"], + output: ["text"], + reasoning: true, + }, + // ... other models +]; +``` + +### Step 8: Update index.ts exports +File: `packages/ai/src/index.ts` + +Export new provider: +```typescript +export { streamGoogleCloudCodeAssist, type GoogleCloudCodeAssistOptions } from "./providers/google-cloud-code-assist.js"; +``` + +--- + +## Phase 2: OAuth Flow in coding-agent + +### Step 9: Create google-cloud.ts OAuth handler +File: `packages/coding-agent/src/core/oauth/google-cloud.ts` + +Implement: +```typescript +import { createHash, randomBytes } from "crypto"; +import { createServer } from "http"; +import { type OAuthCredentials, saveOAuthCredentials } from "./storage.js"; + +const CLIENT_ID = "681255809395-oo8ft2oprdrnp9e3aqf6av3hmdib135j.apps.googleusercontent.com"; +const CLIENT_SECRET = "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"; +const REDIRECT_URI = "http://localhost:8085/oauth2callback"; +const SCOPES = [ + "https://www.googleapis.com/auth/cloud-platform", + "https://www.googleapis.com/auth/userinfo.email", + "https://www.googleapis.com/auth/userinfo.profile", +]; +const AUTH_URL = "https://accounts.google.com/o/oauth2/v2/auth"; +const TOKEN_URL = "https://oauth2.googleapis.com/token"; + +export async function loginGoogleCloud( + onAuth: (info: { url: string; instructions?: string }) => void, + onProgress?: (message: string) => void, +): Promise { + // 1. Generate PKCE + // 2. Start local server on port 8085 + // 3. Build auth URL and call onAuth + // 4. Wait for callback with code + // 5. Exchange code for tokens + // 6. Discover/provision project via loadCodeAssist endpoint + // 7. Return credentials with projectId +} + +export async function refreshGoogleCloudToken(refreshToken: string): Promise { + // Refresh token flow +} + +// Project discovery +async function discoverProject(accessToken: string): Promise { + // Call /v1internal:loadCodeAssist to get project ID + // Or /v1internal:onboardUser if needed +} +``` + +### Step 10: Update oauth/index.ts +File: `packages/coding-agent/src/core/oauth/index.ts` + +- Add `"google-cloud"` to `SupportedOAuthProvider` +- Add to `getOAuthProviders()` list +- Add case in `login()` function +- Add case in `refreshToken()` function + +### Step 11: Update oauth/storage.ts +File: `packages/coding-agent/src/core/oauth/storage.ts` + +Extend `OAuthCredentials` to include optional `projectId`: +```typescript +export interface OAuthCredentials { + type: "oauth"; + refresh: string; + access: string; + expires: number; + enterpriseUrl?: string; + projectId?: string; // For Google Cloud +} +``` + +### Step 12: Update model-config.ts +File: `packages/coding-agent/src/core/model-config.ts` + +Add logic to get API key for `google-cloud-code-assist` provider: +- Check for OAuth token via `getOAuthToken("google-cloud")` +- Return the access token as the "API key" + +--- + +## Phase 3: Testing + +### Manual Testing +1. Run `pi` and use `/login` to authenticate with Google +2. Select a google-cloud-code-assist model +3. Send a message and verify streaming works +4. Test tool calling +5. Test thinking models + +### Verification Points +- [ ] OAuth flow completes successfully +- [ ] Token refresh works +- [ ] Streaming text works +- [ ] Thinking blocks are parsed correctly +- [ ] Tool calls work +- [ ] Tool results are sent correctly +- [ ] Error handling works (rate limits, auth errors) + +--- + +## Notes + +### systemInstruction Format +Must be object with parts, NOT plain string: +```json +{ + "systemInstruction": { + "parts": [{ "text": "You are a helpful assistant." }] + } +} +``` + +### Tool Name Rules +- Must start with letter or underscore +- Allowed: a-zA-Z0-9, underscores, dots, colons, dashes +- Max 64 chars +- No slashes or spaces + +### Thinking Config +```json +{ + "generationConfig": { + "thinkingConfig": { + "thinkingBudget": 8000, + "includeThoughts": true + } + } +} +``` + +### Response Unwrapping +SSE lines come as: +``` +data: {"response": {...}, "traceId": "..."} +``` +Need to extract `response` object which matches standard Gemini format.