mirror of
https://github.com/getcompanion-ai/co-mono.git
synced 2026-04-19 07:03:44 +00:00
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:
parent
04dcdebbc6
commit
36e17933d5
10 changed files with 1208 additions and 178 deletions
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
429
packages/ai/src/providers/google-cloud-code-assist.ts
Normal file
429
packages/ai/src/providers/google-cloud-code-assist.ts
Normal 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)}`,
|
||||
};
|
||||
}
|
||||
203
packages/ai/src/providers/google-shared.ts
Normal file
203
packages/ai/src/providers/google-shared.ts
Normal 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";
|
||||
}
|
||||
}
|
||||
|
|
@ -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}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
312
plan.md
Normal 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.
|
||||
Loading…
Add table
Add a link
Reference in a new issue