feat(ai): add Google Cloud Code Assist provider

- Add new API type 'google-cloud-code-assist' for Gemini CLI / Antigravity auth
- Extract shared Google utilities to google-shared.ts
- Implement streaming provider for Cloud Code Assist endpoint
- Add 7 models: gemini-3-pro-high/low, gemini-3-flash, claude-sonnet/opus, gpt-oss

Models use OAuth authentication and have sh cost (uses Google account quota).
OAuth flow will be implemented in coding-agent in a follow-up.
This commit is contained in:
Mario Zechner 2025-12-20 10:20:30 +01:00
parent 04dcdebbc6
commit 36e17933d5
10 changed files with 1208 additions and 178 deletions

View file

@ -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<string, Record<string, Model<any>>> = {};
for (const model of allModels) {

View file

@ -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";

View file

@ -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;

View file

@ -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<typeof convertTools>;
toolConfig?: {
functionCallingConfig: {
mode: ReturnType<typeof mapToolChoice>;
};
};
};
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<string, unknown>;
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<string, unknown>,
...(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)}`,
};
}

View file

@ -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<T extends GoogleApiType>(model: Model<T>, 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";
}
}

View file

@ -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}`);
}
}
}

View file

@ -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<TApi extends Api>(
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<TApi extends Api>(
} 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<ClampedReasoningEffort, number> = {
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;

View file

@ -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

View file

@ -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)
);
}
/**

312
plan.md Normal file
View file

@ -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<OAuthCredentials & { projectId?: string }> {
// 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<OAuthCredentials> {
// Refresh token flow
}
// Project discovery
async function discoverProject(accessToken: string): Promise<string> {
// 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.