mirror of
https://github.com/getcompanion-ai/co-mono.git
synced 2026-04-21 11:04:35 +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
|
// Group by provider and deduplicate by model ID
|
||||||
const providers: Record<string, Record<string, Model<any>>> = {};
|
const providers: Record<string, Record<string, Model<any>>> = {};
|
||||||
for (const model of allModels) {
|
for (const model of allModels) {
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@ export * from "./agent/index.js";
|
||||||
export * from "./models.js";
|
export * from "./models.js";
|
||||||
export * from "./providers/anthropic.js";
|
export * from "./providers/anthropic.js";
|
||||||
export * from "./providers/google.js";
|
export * from "./providers/google.js";
|
||||||
|
export * from "./providers/google-cloud-code-assist.js";
|
||||||
export * from "./providers/openai-completions.js";
|
export * from "./providers/openai-completions.js";
|
||||||
export * from "./providers/openai-responses.js";
|
export * from "./providers/openai-responses.js";
|
||||||
export * from "./stream.js";
|
export * from "./stream.js";
|
||||||
|
|
|
||||||
|
|
@ -6819,4 +6819,125 @@ export const MODELS = {
|
||||||
maxTokens: 30000,
|
maxTokens: 30000,
|
||||||
} satisfies Model<"openai-completions">,
|
} 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;
|
} 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 {
|
import {
|
||||||
type Content,
|
|
||||||
FinishReason,
|
|
||||||
FunctionCallingConfigMode,
|
|
||||||
type GenerateContentConfig,
|
type GenerateContentConfig,
|
||||||
type GenerateContentParameters,
|
type GenerateContentParameters,
|
||||||
GoogleGenAI,
|
GoogleGenAI,
|
||||||
type Part,
|
|
||||||
type Schema,
|
|
||||||
type ThinkingConfig,
|
type ThinkingConfig,
|
||||||
type ThinkingLevel,
|
type ThinkingLevel,
|
||||||
} from "@google/genai";
|
} from "@google/genai";
|
||||||
|
|
@ -15,20 +10,16 @@ import type {
|
||||||
Api,
|
Api,
|
||||||
AssistantMessage,
|
AssistantMessage,
|
||||||
Context,
|
Context,
|
||||||
ImageContent,
|
|
||||||
Model,
|
Model,
|
||||||
StopReason,
|
|
||||||
StreamFunction,
|
StreamFunction,
|
||||||
StreamOptions,
|
StreamOptions,
|
||||||
TextContent,
|
TextContent,
|
||||||
ThinkingContent,
|
ThinkingContent,
|
||||||
Tool,
|
|
||||||
ToolCall,
|
ToolCall,
|
||||||
} from "../types.js";
|
} from "../types.js";
|
||||||
import { AssistantMessageEventStream } from "../utils/event-stream.js";
|
import { AssistantMessageEventStream } from "../utils/event-stream.js";
|
||||||
import { sanitizeSurrogates } from "../utils/sanitize-unicode.js";
|
import { sanitizeSurrogates } from "../utils/sanitize-unicode.js";
|
||||||
|
import { convertMessages, convertTools, mapStopReason, mapToolChoice } from "./google-shared.js";
|
||||||
import { transformMessages } from "./transorm-messages.js";
|
|
||||||
|
|
||||||
export interface GoogleOptions extends StreamOptions {
|
export interface GoogleOptions extends StreamOptions {
|
||||||
toolChoice?: "auto" | "none" | "any";
|
toolChoice?: "auto" | "none" | "any";
|
||||||
|
|
@ -337,169 +328,3 @@ function buildParams(
|
||||||
|
|
||||||
return params;
|
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 { supportsXhigh } from "./models.js";
|
||||||
import { type AnthropicOptions, streamAnthropic } from "./providers/anthropic.js";
|
import { type AnthropicOptions, streamAnthropic } from "./providers/anthropic.js";
|
||||||
import { type GoogleOptions, streamGoogle } from "./providers/google.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 OpenAICompletionsOptions, streamOpenAICompletions } from "./providers/openai-completions.js";
|
||||||
import { type OpenAIResponsesOptions, streamOpenAIResponses } from "./providers/openai-responses.js";
|
import { type OpenAIResponsesOptions, streamOpenAIResponses } from "./providers/openai-responses.js";
|
||||||
import type {
|
import type {
|
||||||
|
|
@ -77,6 +81,13 @@ export function stream<TApi extends Api>(
|
||||||
case "google-generative-ai":
|
case "google-generative-ai":
|
||||||
return streamGoogle(model as Model<"google-generative-ai">, context, providerOptions);
|
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: {
|
default: {
|
||||||
// This should never be reached if all Api cases are handled
|
// This should never be reached if all Api cases are handled
|
||||||
const _exhaustive: never = api;
|
const _exhaustive: never = api;
|
||||||
|
|
@ -196,6 +207,29 @@ function mapOptionsForApi<TApi extends Api>(
|
||||||
} satisfies GoogleOptions;
|
} 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: {
|
default: {
|
||||||
// Exhaustiveness check
|
// Exhaustiveness check
|
||||||
const _exhaustive: never = model.api;
|
const _exhaustive: never = model.api;
|
||||||
|
|
|
||||||
|
|
@ -1,18 +1,25 @@
|
||||||
import type { AnthropicOptions } from "./providers/anthropic.js";
|
import type { AnthropicOptions } from "./providers/anthropic.js";
|
||||||
import type { GoogleOptions } from "./providers/google.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 { OpenAICompletionsOptions } from "./providers/openai-completions.js";
|
||||||
import type { OpenAIResponsesOptions } from "./providers/openai-responses.js";
|
import type { OpenAIResponsesOptions } from "./providers/openai-responses.js";
|
||||||
import type { AssistantMessageEventStream } from "./utils/event-stream.js";
|
import type { AssistantMessageEventStream } from "./utils/event-stream.js";
|
||||||
|
|
||||||
export 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 {
|
export interface ApiOptionsMap {
|
||||||
"anthropic-messages": AnthropicOptions;
|
"anthropic-messages": AnthropicOptions;
|
||||||
"openai-completions": OpenAICompletionsOptions;
|
"openai-completions": OpenAICompletionsOptions;
|
||||||
"openai-responses": OpenAIResponsesOptions;
|
"openai-responses": OpenAIResponsesOptions;
|
||||||
"google-generative-ai": GoogleOptions;
|
"google-generative-ai": GoogleOptions;
|
||||||
|
"google-cloud-code-assist": GoogleCloudCodeAssistOptions;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Compile-time exhaustiveness check - this will fail if ApiOptionsMap doesn't have all KnownApi keys
|
// 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).
|
* Check if input matches Ctrl+K (raw byte or Kitty protocol).
|
||||||
* Ignores lock key bits.
|
* 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 {
|
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