blob: 1570e82fb924747cf69ffce1b68f235cd79f5c51 [file]
// Copyright (C) 2026 The Android Open Source Project
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
import {parseSse} from '../../base/sse';
import {z} from 'zod';
import type {
AvailableModel,
CredentialField,
Message,
Protocol,
ProtocolCapabilities,
Request,
StreamEvent,
} from '../dev.perfetto.Llm/protocol';
import {zodToGeminiSchema} from './schema_converter';
// Note: v1beta is required for thinking config
const ENDPOINT = 'https://generativelanguage.googleapis.com/v1beta';
interface GeminiTextPart {
readonly text: string;
readonly thought?: boolean;
}
interface GeminiFunctionCallPart {
readonly functionCall: {
readonly name: string;
readonly args: Record<string, unknown>;
};
// Encrypted reasoning state Gemini attaches to (the first) functionCall part.
// The API rejects a follow-up request that echoes a function call back
// without it ("missing a thought_signature"), so we capture it on the way out
// and replay it verbatim on the way back.
readonly thoughtSignature?: string;
}
interface GeminiFunctionResponsePart {
readonly functionResponse: {
readonly name: string;
readonly response: {readonly result: unknown};
};
}
type GeminiPart =
| GeminiTextPart
| GeminiFunctionCallPart
| GeminiFunctionResponsePart;
interface GeminiContent {
readonly role: 'user' | 'model';
readonly parts: readonly GeminiPart[];
}
interface GeminiUsage {
readonly promptTokenCount?: number;
readonly candidatesTokenCount?: number;
readonly totalTokenCount?: number;
}
interface GeminiCandidate {
readonly content?: GeminiContent;
readonly finishReason?: string;
}
interface GeminiStreamChunk {
readonly candidates?: readonly GeminiCandidate[];
readonly usageMetadata?: GeminiUsage;
}
function messagesToContents(messages: readonly Message[]): GeminiContent[] {
const contents: GeminiContent[] = [];
for (const msg of messages) {
switch (msg.role) {
case 'user':
contents.push({role: 'user', parts: [{text: msg.text}]});
break;
case 'model':
contents.push({role: 'model', parts: [{text: msg.text}]});
break;
case 'tool-call':
// Gemini expects tool calls back from the model exactly as it emitted
// them, attributed to the 'model' role - including the thoughtSignature
// it stamped on the call (carried opaquely on the neutral call).
contents.push({
role: 'model',
parts: msg.calls.map((c) => ({
functionCall: {name: c.name, args: c.args},
...(c.signature ? {thoughtSignature: c.signature} : {}),
})),
});
break;
case 'tool-result':
// Gemini threads results by tool *name* (no id), under the 'user' role.
contents.push({
role: 'user',
parts: msg.results.map((r) => ({
functionResponse: {
name: r.name,
response: {result: r.result},
},
})),
});
break;
}
}
return contents;
}
function toolsToDeclarations(request: Request) {
if (request.tools.length === 0) return undefined;
return [
{
functionDeclarations: request.tools.map((t) => ({
name: t.name,
description: t.description,
parameters: zodToGeminiSchema(t.inputSchema),
})),
},
];
}
function classifyError(status: number, body: string): StreamEvent {
let kind: 'rate-limit' | 'auth' | 'context-length' | 'network' | 'unknown';
if (status === 429) kind = 'rate-limit';
else if (status === 401 || status === 403) kind = 'auth';
else if (status === 400 && /token|context|too long/i.test(body)) {
kind = 'context-length';
} else kind = 'unknown';
return {
type: 'stop',
reason: 'error',
error: {kind, message: `Gemini API error ${status}: ${body}`},
};
}
// Response of GET /models (only the fields we consume). Unknown keys are
// dropped by zod, so this tolerates the many other fields Gemini returns.
const LIST_MODELS_RESPONSE = z.object({
models: z.array(z.object({name: z.string().optional()})).optional(),
});
const CAPABILITIES: ProtocolCapabilities = {
nativeToolCalling: true,
streaming: true,
};
const CREDENTIAL_FIELDS: readonly CredentialField[] = [
{
key: 'apiKey',
label: 'API key',
secret: true,
required: true,
placeholder: 'AIza...',
},
{
key: 'endpoint',
label: 'Endpoint (optional)',
placeholder: ENDPOINT,
},
];
export class GeminiProtocol implements Protocol {
readonly id = 'gemini';
readonly label = 'Google Gemini';
readonly capabilities = CAPABILITIES;
readonly credentialFields = CREDENTIAL_FIELDS;
// GET {base}/models lists every model the key can reach. The names come back
// prefixed ('models/gemini-2.5-flash'); strip that to the bare model name the
// generateContent path expects.
async listModels(
credentials: Readonly<Record<string, string>>,
signal?: AbortSignal,
): Promise<readonly AvailableModel[]> {
const apiKey = credentials.apiKey ?? '';
const base = credentials.endpoint || ENDPOINT;
const resp = await fetch(`${base}/models?key=${apiKey}&pageSize=1000`, {
signal,
});
if (!resp.ok) {
const text = await resp.text().catch(() => '<no body>');
throw new Error(`Gemini listModels error ${resp.status}: ${text}`);
}
const json = LIST_MODELS_RESPONSE.parse(await resp.json());
return (json.models ?? [])
.map((m) => m.name ?? '')
.filter((name) => name !== '')
.map((name) => ({name: name.replace(/^models\//, '')}));
}
async *createStream(
model: string,
request: Request,
credentials: Readonly<Record<string, string>>,
signal?: AbortSignal,
): AsyncGenerator<StreamEvent, void, void> {
const apiKey = credentials.apiKey ?? '';
const base = credentials.endpoint || ENDPOINT;
const url = `${base}/models/${model}:streamGenerateContent?alt=sse&key=${apiKey}`;
const toolDeclarations = toolsToDeclarations(request);
const body = {
contents: messagesToContents(request.messages),
systemInstruction: {parts: [{text: request.systemPrompt}]},
...(toolDeclarations ? {tools: toolDeclarations} : {}),
generationConfig: {
thinkingConfig: {includeThoughts: true, thinkingBudget: -1},
},
};
let resp: Response;
try {
resp = await fetch(url, {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify(body),
signal,
});
} catch (e) {
// Aborts surface as a thrown DOMException; let the consumer see the
// cancellation rather than dressing it up as an error event.
if (signal?.aborted) return;
yield {
type: 'stop',
reason: 'error',
error: {kind: 'network', message: String(e)},
};
return;
}
if (!resp.ok || !resp.body) {
const text = await resp.text().catch(() => '<no body>');
yield classifyError(resp.status, text);
return;
}
let sawToolCall = false;
let stopReason: 'end' | 'length' = 'end';
for await (const rawChunk of parseSse(resp.body, signal)) {
const chunk = rawChunk as GeminiStreamChunk;
const candidate = chunk.candidates?.[0];
for (const part of candidate?.content?.parts ?? []) {
if ('text' in part) {
if (part.thought) {
yield {type: 'thought', text: part.text};
} else {
yield {type: 'text', text: part.text};
}
} else if ('functionCall' in part) {
sawToolCall = true;
yield {
type: 'tool-call',
call: {
name: part.functionCall.name,
args: part.functionCall.args ?? {},
// Carry the signature opaquely so the agent can hand it back on
// the next request (Gemini requires it echoed verbatim).
signature: part.thoughtSignature,
},
};
}
}
if (candidate?.finishReason === 'MAX_TOKENS') stopReason = 'length';
if (chunk.usageMetadata) {
yield {
type: 'usage',
usage: {
inputTokens: chunk.usageMetadata.promptTokenCount,
outputTokens: chunk.usageMetadata.candidatesTokenCount,
totalTokens: chunk.usageMetadata.totalTokenCount,
},
};
}
}
if (signal?.aborted) return;
yield {
type: 'stop',
reason: sawToolCall ? 'tool-calls' : stopReason,
};
}
}