zz_

app/agent/geminiBackend.ts

zzmq.dev@gmail.com/zz / app / agent / geminiBackend.ts @ 153aad69 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": apiKey() },
+ 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;
  },
  };
 }