agentfootprint
Version:
The explainable agent framework — backtrack a wrong answer to the exact context that caused it (evidence, not guesses). Built on footprintjs.
286 lines (285 loc) • 14.1 kB
TypeScript
/**
* GeminiProvider — wraps `@google/genai` as an `LLMProvider` (9.13.0).
*
* Pattern: Adapter (GoF) + Ports-and-Adapters (Cockburn 2005).
* Role: Outer ring — translates `LLMRequest`/`LLMResponse` to/from Google's
* `generateContent` API. Knows nothing about agents, recorders, or
* compositions.
* Emits: N/A — providers don't emit; recorders observe via Agent.
*
* ─── Two doors, one adapter ─────────────────────────────────────────
*
* The same SDK reaches two different services, and which one you get is a
* property of the OPTIONS, not of a separate factory:
*
* • **Vertex** — `gemini({ project, location })` builds
* `new GoogleGenAI({ vertexai: true, project, location, googleAuthOptions })`.
* Auth is Application Default Credentials (env key file, gcloud user
* credentials, or the metadata server on GCE / Cloud Run / Agent Runtime).
* No API key exists on this path.
* • **Gemini API (AI Studio)** — `gemini({ apiKey })` builds
* `new GoogleGenAI({ apiKey })`. One key, no cloud project.
*
* Neither is guessed. The factory refuses at construction when it can resolve
* neither a project nor a key, naming both doors — because the SDK's own
* behaviour in that case is to WARN on stderr, construct anyway, and fail on
* the first call, which reads like a network problem rather than a missing
* setting.
*
* ─── Status, per door, because the doors differ ─────────────────────
*
* • **Vertex: field-validated.** An independent trial ran this adapter,
* unchanged since 9.13.0, against a live project through Application Default
* Credentials: a two-call tool loop on `gemini-2.5-flash` returning the
* expected answer with real token counts, and a stream delivering 10 content
* chunks that reconstructed exactly (FINDINGS "Part 1 — native Gemini on
* Vertex through ADC", "Part 2C"). `geminiEmbedder` passed on the same door.
* • **Gemini API (AI Studio): NOT field-validated, and not for want of
* trying.** The trial's key-door calls never reached a model: the old
* default model answered 404 for a new account, and a current model answered
* 429 for AI Studio prepayment, which is billing SEPARATE from the Google
* Cloud account's credit. What that door does with a funded key is
* untested here, which is why it has no default model.
* • **Tool calling on a 3.x model: field-validated.** The signature round trip
* below was built from the trial's captured 400, and the same independent
* trial then re-ran it on live Vertex against the published fix
* (2026-08-14): `gemini-3.1-flash-lite` called a tool, the tool answered,
* the signature went back byte for byte, and the second model call
* completed with the right answer — two LLM calls, one tool execution, real
* token counts. What it does NOT cover is a signature attached to a TEXT
* part with no function call beside it; see below.
*
* ─── Why native, and not `openai({ baseURL })` ──────────────────────
*
* Google publishes an OpenAI-compatible endpoint, and it does work — for about
* an hour. It authenticates with an OAuth access token that expires (9.29.0
* gives `openai({ apiKey })` a callback form so a long-running process can
* refresh one, which is a mitigation and not a fix); its
* `tools.function.parameters` is **OpenAPI**, not JSON Schema, so `$ref` /
* `oneOf` / `additionalProperties` diverge silently; unsupported parameters are
* ignored rather than refused; and its documented response carries no cached-
* or reasoning-token counts. This adapter exists because every one of those is
* answerable on the native SDK: ADC refreshes itself, tools go over as JSON
* Schema untranslated (`parametersJsonSchema`), a forced single tool is
* expressible, and `usageMetadata` reports cached and thinking tokens as
* separate numbers.
*
* A field trial measured both halves of that: the native path completed a call
* after a forced credential expiry, while the compatible path returned HTTP 401
* on an expired token (FINDINGS "Part 2B").
*
* ─── Thought signatures: the round trip a tool loop cannot skip ─────
*
* A current Gemini model does not merely PREFER its thought signature back —
* it refuses the turn without it. An independent field trial on live GCP ran
* `gemini-3.1-flash-lite` on Vertex through one ordinary tool loop: the first
* call asked for the function, the tool ran, and the second call came back
*
* 400 INVALID_ARGUMENT — "Function call is missing a thought_signature in
* functionCall parts. This is required for tools to work correctly …"
*
* (FINDINGS "Failure 4"). The cost of that shape is what makes it worth this
* paragraph: the failure lands AFTER the tool has already executed, so a
* side-effecting tool has run and the answer is unreachable.
*
* So the signature is CARRIED, not dropped. `Part.thoughtSignature` (a base64
* string on the installed @google/genai 2.16.0 `Part`) is read off the same
* part as the `functionCall` it belongs to, parked on the port's neutral
* `toolCalls[].providerMeta`, and written back onto the reconstructed
* `functionCall` part on the next request — byte for byte, because a signature
* is checked and not read. This is the same round trip the Anthropic adapter
* does with signed thinking blocks, one field along: there the carrier is
* `LLMMessage.thinkingBlocks[].signature`, here it is the tool call itself,
* because that is where Google puts it.
*
* What that does NOT cover, said plainly: a signature attached to a TEXT part
* of an answer with no function call. The port's assistant turn is a string,
* and a string has nowhere to keep one. The trial's failure was the functionCall
* shape; that shape is fixed here, and the other one is named rather than
* quietly half-handled.
*
* ─── Limitations (stated, not discovered) ───────────────────────────
*
* • Multi-modal input/output NOT supported — `LLMMessage.content` is `string`,
* so this adapter sends and reads text parts only. Inline data, files and
* generated images are out of scope for the port as it stands.
* • **Thought summaries are not requested.** `usage.thinking` is reported
* honestly from `thoughtsTokenCount`, and `req.thinking.budget` is threaded
* to `thinkingConfig.thinkingBudget` — but `includeThoughts` is deliberately
* left unset, so no `rawThinking` is produced and there is no Gemini
* `ThinkingHandler` in this release to normalize thought TEXT. Asking for
* content nothing can carry would be a leak, not a feature. Signature
* round-tripping (above) is a separate thing and does not need one.
* `thinkingConfig.thinkingLevel` has no field on `LLMRequest` and is
* likewise not sent.
* • `responseJsonSchema` (native structured output) is NOT exposed; use
* `.outputSchema(...)`, which goes over as a forced tool — a shape this
* adapter DOES carry.
* • Grounding tools (Google Search, code execution, URL context, MCP servers)
* are not exposed. `tools` carries function declarations only.
*/
import type { LLMCallHooks, LLMChunk, LLMProvider, LLMRequest, LLMResponse, WireRole } from '../../provider-contract/types.js';
import { type GoogleGenAIConnectionOptions } from './googleGenAI.js';
/** One `parts[]` entry. Only the members this adapter reads or writes. */
export interface GeminiPart {
readonly text?: string;
/** `true` on a thought-summary part. Never joined into visible content. */
readonly thought?: boolean;
/**
* The model's opaque signature for the thinking behind THIS part — base64,
* per `Part.thoughtSignature` on the installed @google/genai 2.16.0 surface.
*
* Read off a `functionCall` part and written back onto the same part on the
* next request. Never parsed, never trimmed, never regenerated: the service
* verifies it, so the only correct handling is byte-for-byte.
*/
readonly thoughtSignature?: string;
readonly functionCall?: {
readonly id?: string;
readonly name?: string;
readonly args?: Record<string, unknown>;
};
readonly functionResponse?: {
readonly id?: string;
readonly name?: string;
readonly response?: Record<string, unknown>;
};
}
/** One conversation turn. Gemini's assistant role is spelled `'model'`. */
export interface GeminiContent {
readonly role: 'user' | 'model';
readonly parts: readonly GeminiPart[];
}
/** A `FunctionDeclaration`, JSON-Schema flavour. */
export interface GeminiFunctionDeclaration {
readonly name: string;
readonly description: string;
/**
* JSON Schema, passed through UNCHANGED. The sibling `parameters` field
* takes OpenAPI instead and is never set here — sending both is what makes
* an adapter's tool schemas quietly disagree with the ones it was given.
*/
readonly parametersJsonSchema: Record<string, unknown>;
}
export interface GeminiGenerateConfig {
systemInstruction?: string;
temperature?: number;
maxOutputTokens?: number;
stopSequences?: string[];
abortSignal?: AbortSignal;
tools?: {
functionDeclarations: GeminiFunctionDeclaration[];
}[];
toolConfig?: {
functionCallingConfig: {
mode: 'AUTO' | 'ANY' | 'NONE';
allowedFunctionNames?: string[];
};
};
thinkingConfig?: {
thinkingBudget: number;
};
}
export interface GeminiGenerateParams {
readonly model: string;
readonly contents: readonly GeminiContent[];
readonly config?: GeminiGenerateConfig;
}
export interface GeminiUsageMetadata {
readonly promptTokenCount?: number;
readonly candidatesTokenCount?: number;
readonly cachedContentTokenCount?: number;
readonly thoughtsTokenCount?: number;
readonly totalTokenCount?: number;
}
export interface GeminiCandidate {
readonly content?: {
readonly parts?: readonly GeminiPart[];
};
readonly finishReason?: string;
}
export interface GeminiGenerateResponse {
readonly candidates?: readonly GeminiCandidate[];
readonly usageMetadata?: GeminiUsageMetadata;
readonly responseId?: string;
}
/**
* The `models` namespace, narrowed to the two operations this adapter
* dispatches.
*
* `countTokens` is deliberately ABSENT. It exists on the real namespace and it
* would be an easy way to fill in a usage number the wire did not report —
* which is precisely why it is not here. A count we asked for separately is not
* what the call was billed for; see the streaming note on `stream()`.
*/
export interface GeminiModelsLike {
generateContent(params: GeminiGenerateParams): Promise<GeminiGenerateResponse>;
/** Note the double await: the SDK returns a PROMISE of an async generator. */
generateContentStream(params: GeminiGenerateParams): Promise<AsyncIterable<GeminiGenerateResponse>>;
}
/** What `new GoogleGenAI(...)` gives us, narrowed to what we use. */
export interface GeminiClientLike {
readonly models: GeminiModelsLike;
}
export interface GeminiProviderOptions extends GoogleGenAIConnectionOptions {
/**
* Default model used when `LLMRequest.model` is `'gemini'` (the shorthand).
* A request naming a full model id wins.
*
* **Unset behaves differently per door, because the doors behave
* differently.** On **Vertex** the shorthand resolves to
* `'gemini-2.5-flash'`, which a live field trial ran end to end through ADC.
* On the **Gemini API (AI Studio)** door the shorthand is REFUSED by name:
* the same trial's key-door call to that exact model returned HTTP 404 —
* *"no longer available to new users"* (FINDINGS "Failure 1") — and no model
* could be proven working on that door at all, because the account's separate
* AI Studio prepayment was empty (FINDINGS "Failure 2", a 429 on
* `gemini-3.1-flash-lite`, which at least proves that id resolves there).
*
* Shipping a second silent default nobody has run would be this library
* guessing on a service's behalf; the refusal names the door and the fix.
*/
readonly defaultModel?: string;
/** Default `maxOutputTokens` when the request doesn't set it. Optional. */
readonly defaultMaxTokens?: number;
/** @internal Pre-built client for testing. Skips the SDK import entirely. */
readonly _client?: GeminiClientLike;
}
/**
* Build an `LLMProvider` backed by Gemini — on Vertex or on the Gemini API.
*
* @example Vertex (Application Default Credentials)
* ```ts
* import { Agent } from 'agentfootprint';
* import { gemini } from 'agentfootprint/providers';
*
* const agent = Agent.create({
* provider: gemini({ project: 'my-project', location: 'us-central1' }),
* model: 'gemini',
* })
* .tool(weatherTool)
* .build();
* ```
*
* @example Gemini API (one key, no cloud project)
* ```ts
* const provider = gemini({ apiKey: process.env.GEMINI_API_KEY });
* ```
*
* @throws at construction when neither a project nor an API key is resolvable,
* and when `@google/genai` is not installed.
*/
export declare function gemini(options?: GeminiProviderOptions): LLMProvider;
/**
* Class form for consumers who prefer `new GeminiProvider(...)` over the
* `gemini(...)` factory. Identical behavior; trivial wrapper.
*/
export declare class GeminiProvider implements LLMProvider {
readonly name = "gemini";
readonly carriesInMessages: readonly WireRole[];
readonly carriesForcedToolChoice = true;
private readonly inner;
constructor(options?: GeminiProviderOptions);
complete(req: LLMRequest, hooks?: LLMCallHooks): Promise<LLMResponse>;
stream(req: LLMRequest, hooks?: LLMCallHooks): AsyncIterable<LLMChunk>;
}