app/agent/geminiBackend.ts
zzmq.dev@gmail.com/zz / app / agent / geminiBackend.ts
@ 2a73adc2
Download
Ladder Summary
- file The file implements a **Gemini‑API chat backend** that translates the project’s internal `ChatMessage` format into the request shape required by Google’s Gemini model, sends the request, parses the response (including tool/function calls), and returns a `ChatBackend` compatible object. **Exported symbols** - `createGeminiBackend(model: string): ChatBackend` – factory that validates the API key, builds the request payload, performs the HTTP call, streams the final text via `onPartial`, and converts the Gemini response back to a `ChatMessage`.
// Google AI Studio (Gemini API) as a remote alternative to local
// Ollama -- mainly to isolate how much of this project's slowness is
// local-model compute vs. the surrounding system, since a production-
// grade remote serving stack removes the local-hardware variable
// entirely. Verified request/response shape directly against Google's
// own API reference (contents[]/parts[], systemInstruction,
// functionCall/functionResponse) rather than assumed.
import type { ChatBackend, ChatMessage } from "./chatBackend.js";
import type { ToolDef } from "./tools.js";
function apiKey(): string {
const key = process.env.GEMINI_API_KEY ?? process.env.GOOGLE_AI_API_KEY;
if (!key) throw new Error('Gemini backend needs an API key: set GEMINI_API_KEY (or GOOGLE_AI_API_KEY) in your environment or app/.env. Get a free-tier key at https://aistudio.google.com/apikey -- never paste the key itself into chat.');
return key;
}
function toGeminiTools(tools: ToolDef[]) {
return [{ functionDeclarations: tools.map((t) => ({ name: t.name, description: t.description, parameters: t.parameters })) }];
}
interface GeminiPart {
text?: string;
functionCall?: { name: string; args: Record<string, unknown>; thoughtSignature?: string };
functionResponse?: { name: string; response: Record<string, unknown> };
}
// Gemini has no "system" role and no "tool" role: the system message
// becomes systemInstruction (pulled out separately, never part of
// contents[]), and a tool result becomes a "user"-role functionResponse
// part -- its own real convention, not a guess, per the verified docs.
function toGeminiContents(messages: ChatMessage[]): { systemInstruction: { parts: GeminiPart[] } | undefined; contents: { role: string; parts: GeminiPart[] }[] } {
let systemInstruction: { parts: GeminiPart[] } | undefined;
const contents: { role: string; parts: GeminiPart[] }[] = [];
for (const m of messages) {
if (m.role === "system") {
systemInstruction = { parts: [{ text: m.content }] };
continue;
}
if (m.role === "tool") {
let response: Record<string, unknown>;
try {
response = JSON.parse(m.content);
} catch {
response = { result: m.content };
}
contents.push({ role: "user", parts: [{ functionResponse: { name: m.name ?? "unknown", response } }] });
continue;
}
const parts: GeminiPart[] = [];
if (m.content) parts.push({ text: m.content });
for (const call of m.tool_calls ?? []) {
// Prefer replaying the exact functionCall part Gemini gave us
// (thoughtSignature and all) over reconstructing one -- a
// reconstructed call missing thoughtSignature is a real,
// confirmed 400 from the API, not just a quality nit.
const raw = call.raw as GeminiPart["functionCall"] | undefined;
parts.push({ functionCall: raw ?? { name: call.function.name, args: call.function.arguments } });
}
contents.push({ role: m.role === "assistant" ? "model" : "user", parts });
}
return { systemInstruction, contents };
}
function fromGeminiResponse(data: { candidates?: { content?: { parts?: GeminiPart[] } }[] }): ChatMessage {
const parts = data.candidates?.[0]?.content?.parts ?? [];
let content = "";
const toolCalls: NonNullable<ChatMessage["tool_calls"]> = [];
for (const p of parts) {
if (p.text) content += p.text;
if (p.functionCall) toolCalls.push({ function: { name: p.functionCall.name, arguments: p.functionCall.args }, raw: p.functionCall });
}
return { role: "assistant", content, tool_calls: toolCalls.length ? toolCalls : undefined };
}
export function createGeminiBackend(model: string): ChatBackend {
// Resolved eagerly, not inside chat() -- callers (zz work/--assist)
// call this before resolveCliBackendMode/enterFullscreen,
// specifically so a missing key fails fast as a plain CLI error
// instead of surfacing deep inside a TUI session on the first draft
// attempt.
const key = apiKey();
return {
name: `gemini:${model}`,
async chat(messages, tools, onPartial) {
const { systemInstruction, contents } = toGeminiContents(messages);
const res = await fetch(`https://generativelanguage.googleapis.com/v1beta/models/${model}:generateContent`, {
method: "POST",
headers: { "content-type": "application/json", "x-goog-api-key": key },
body: JSON.stringify({ contents, systemInstruction, ...(tools.length ? { tools: toGeminiTools(tools) } : {}) }),
});
if (!res.ok) throw new Error(`gemini chat failed: ${res.status} ${await res.text()}`);
const data = await res.json();
const message = fromGeminiResponse(data);
// No streaming here yet (this call is non-streaming) -- still
// report the final content once, so callers that only care about
// "what's the latest text" (a TUI status line) don't need a
// separate non-streaming code path.
if (message.content) onPartial?.(message.content);
return message;
},
};
}