UNPKG

agentfootprint

Version:

The explainable agent framework — backtrack a wrong answer to the exact context that caused it (evidence, not guesses). Built on footprintjs.

250 lines • 13.5 kB
/** * googleGenAI — the one place a `GoogleGenAI` client gets built (9.13.0). * * Two adapters reach for the same SDK client — `gemini()` (the LLM provider) * and `geminiEmbedder()` — and they need the SAME two doors, the SAME refusals * and the SAME `_client` seam. Duplicating that is how the two drift: one grows * a fix, the other keeps the bug, and the difference is invisible until a * consumer hits the door that was not fixed. * * So the connection lives here and the adapters own only their own calls. * Nothing in this file knows what a model, a message or a vector is. * * ─── The two doors ────────────────────────────────────────────────── * * • **Vertex** — `{ project, location }` → `new GoogleGenAI({ vertexai: true, * project, location, googleAuthOptions })`. Credentials come from * Application Default Credentials: `GOOGLE_APPLICATION_CREDENTIALS`, the * gcloud user credentials, or the metadata server on GCE / Cloud Run. There * is no API key on this path. * • **Gemini API (AI Studio)** — `{ apiKey }` → `new GoogleGenAI({ apiKey })`. * One key, no cloud project. * * ─── The API version, which is not what you would guess ───────────── * * The SDK's DEFAULT API version is `v1beta1` on Vertex and `v1beta` on the * Gemini API — verified against @google/genai 2.16.0 and pinned by * `test/adapters/google/google-surface-pin.test.ts`. It is not `v1`. Callers * who need a specific version pass `apiVersion`; callers who do not at least * find the truth stated instead of assuming a GA path. * * ─── The key can be a CALLBACK, and why (9.29.0) ──────────────────── * * `GoogleGenAIOptions.apiKey` is `string` on the installed SDK (@google/genai * 2.16.0 `genai.d.ts`) — one value, fixed at construction. That is fine for an * AI Studio key, which does not expire, and wrong for every credential that * does: a Vertex OAuth access token lives about an hour, and a key pulled from * a secret manager is rotated on somebody else's schedule. * * An independent field trial on live GCP measured exactly that boundary: an * OAuth token that worked returned HTTP 401 once expired, with no place in the * options to put a fresh one ("Part 2B — ADC refresh versus OpenAI-compatible * OAuth"). So `apiKey` widens to `string | (() => string | Promise<string>)`, * and the refresh boundary is stated rather than implied: * * • The callback runs ONCE PER REQUEST, before the request is built. * • The SDK client is REBUILT only when the returned string differs from the * one already in hand — a callback that returns a cached token costs one * function call and nothing else. * • A stream keeps the key it STARTED with. The boundary is the call, not * the chunk; nothing here can re-authenticate a socket that is already * open. * • Vertex needs none of this. ADC refreshes itself, which the same trial * verified by forcing an in-memory expiry and completing the next call. */ import { lazyRequire } from '../../lib/lazyRequire.js'; /** An empty environment variable is not a setting — see `resolveGoogleGenAIClient`. */ const set = (value) => value !== undefined && value.trim().length > 0 ? value : undefined; /** The env this module reads, or `{}` off-Node. */ const environment = () => (typeof process !== 'undefined' ? process.env : {}); /** * Which door these options open. * * The order mirrors `resolveGoogleGenAIClient` exactly, because a door that * disagreed with the client that gets built is worse than no door at all. * * @param readEnv `false` when the caller injected a `_client`. A double talks * to no service, so the ambient `GEMINI_API_KEY` of whoever is running * the tests describes a door that will never be dialled — reading it * would make an offline suite depend on a developer's shell. */ export function resolveGoogleDoor(options, readEnv = true) { if (options.vertexai === true) return 'vertex'; if (options.vertexai === false) return 'gemini-api'; const env = readEnv ? environment() : {}; if ((set(options.project) ?? set(env.GOOGLE_CLOUD_PROJECT)) !== undefined) return 'vertex'; const key = (typeof options.apiKey === 'function' ? 'callback' : set(options.apiKey)) ?? set(env.GEMINI_API_KEY) ?? set(env.GOOGLE_API_KEY); return key !== undefined ? 'gemini-api' : 'vertex'; } /** * Build (or accept) a `GoogleGenAI` client. * * `injected` is the `_client` test seam: a structural double exposing only the * namespace members the calling adapter uses. When it is present the SDK is * never required, no environment is read and no refusal fires — a test drives * the adapter's translation with no Google anywhere near it. * * @param factory the public name to put in refusals — `'gemini'`, * `'geminiEmbedder'`. A message that names the wrong factory * sends the reader to the wrong line of their own code. * @param apiKey the key STRING for this build. Callers holding a callback * resolve it first (see {@link createGoogleGenAIClientResolver}); * a function reaching the SDK's `apiKey` field would be sent * as `[object Function]` and fail as an auth error. * @throws when neither a project nor an API key is resolvable; when * `vertexai: true` is asked for without a project; and when * `@google/genai` is not installed. */ export function resolveGoogleGenAIClient(options, factory, injected, apiKey) { if (injected) return injected; const env = environment(); // Read HERE only to decide whether there is enough configuration to build // anything at all. The values are never copied into the constructor, so the // SDK stays the single resolver of its own environment variables — one place // that answers "where did my project come from", not two that can disagree. // An EMPTY variable is not a setting. `GOOGLE_CLOUD_PROJECT=` in a shell or a // CI matrix is how a project comes to be "present" and unusable — the guards // below would pass and the SDK would fail on the first call instead. const project = set(options.project) ?? set(env.GOOGLE_CLOUD_PROJECT); const resolvedKey = set(apiKey) ?? (typeof options.apiKey === 'string' ? set(options.apiKey) : undefined) ?? set(env.GEMINI_API_KEY) ?? set(env.GOOGLE_API_KEY); const vertexai = options.vertexai ?? (project !== undefined ? true : undefined); if (vertexai !== false && project === undefined && resolvedKey === undefined) { throw new Error(`${factory}: no Google project and no API key — this factory cannot tell which service ` + 'you meant, and the SDK would warn on stderr, construct anyway, and fail on the first ' + 'call with something that reads like a network problem.\n' + ` Vertex: ${factory}({ project: "my-project", location: "us-central1" }) — or set ` + 'GOOGLE_CLOUD_PROJECT; credentials come from Application Default Credentials.\n' + ` Gemini API: ${factory}({ apiKey: process.env.GEMINI_API_KEY }) — or set ` + 'GEMINI_API_KEY.\n' + ` Tests: ${factory}({ _client }) with a { models: { … } } double — no SDK, no ` + 'network, no credentials.'); } if (vertexai === true && project === undefined) { throw new Error(`${factory}: \`vertexai: true\` selects Vertex, which is addressed by PROJECT and not by ` + 'key. Pass `{ project }` (or set GOOGLE_CLOUD_PROJECT), or drop `vertexai` and pass ' + '`{ apiKey }` to use the Gemini API instead.'); } let GoogleGenAI; try { const mod = lazyRequire('@google/genai'); GoogleGenAI = (mod.GoogleGenAI ?? mod.default?.GoogleGenAI); } catch { throw new Error(`${factory} requires the \`@google/genai\` package.\n` + ' Install: npm install @google/genai\n' + ' Or pass `_client` for test injection.'); } if (!GoogleGenAI) { throw new Error(`${factory}: \`@google/genai\` is installed but exports no \`GoogleGenAI\`. These adapters ` + 'are built against @google/genai 2.x — update the package.'); } // Only what was ASKED for goes on the wire; everything omitted is left to the // SDK's own environment resolution. The key is the one exception: the // CALLBACK form has already been called by the time we get here, so what // travels is the string it answered with — never the function. const key = apiKey ?? (typeof options.apiKey === 'string' ? options.apiKey : undefined); return new GoogleGenAI({ ...(vertexai !== undefined && { vertexai }), ...(options.project !== undefined && { project: options.project }), ...(options.location !== undefined && { location: options.location }), ...(key !== undefined && { apiKey: key }), ...(options.apiVersion !== undefined && { apiVersion: options.apiVersion }), ...(options.googleAuthOptions !== undefined && { googleAuthOptions: options.googleAuthOptions, }), }); } /** * The client seam every Google adapter calls through — one per request. * * Three paths, and only the third one is new: * * 1. **`_client` injected** — the double, always, no SDK, no environment. * Any `apiKey` still rides along on the lease so redaction tests can * prove a secret never reaches an error message. * 2. **`apiKey` a string (or absent)** — the client is built ONCE. With * `eager`, it is built at factory time, so a missing peer dependency and * an unanswerable door are still refused where the consumer typed the * call rather than on their first request. * 3. **`apiKey` a callback** — called before every request. The client is * rebuilt only when the answer CHANGED, so a cached token costs one * function call. Construction cannot be eager here: calling a consumer's * credential provider from a factory would fetch a token nobody asked * for yet, and possibly before their own setup ran. * * @param eager build now (path 2 only), preserving construction-time refusals. */ export function createGoogleGenAIClientResolver(options, factory, injected, eager = false) { const source = options.apiKey; if (injected) { // A double stands in for the SDK CLIENT, not for the credential. So a // callback is still called per request here: it is the only way a test can // watch a key rotate, and the only way redaction can be proved against the // key that is actually in force rather than the one at construction. if (typeof source !== 'function') { const lease = { client: injected, ...(source !== undefined && { apiKey: source }), }; return async () => lease; } return async () => ({ client: injected, apiKey: await resolveKey(source, factory) }); } if (typeof source !== 'function') { let built = eager ? resolveGoogleGenAIClient(options, factory, undefined) : undefined; return async () => { built ??= resolveGoogleGenAIClient(options, factory, undefined); return { client: built, ...(source !== undefined && { apiKey: source }) }; }; } // The callback path. `lastKey` is held so the SDK client survives a callback // that keeps answering the same thing — which is what a well-behaved token // cache does on all but one call in a thousand. let lastKey; let built; return async () => { const answer = await resolveKey(source, factory); if (built === undefined || answer !== lastKey) { built = resolveGoogleGenAIClient(options, factory, undefined, answer); lastKey = answer; } return { client: built, apiKey: answer }; }; } /** Call the credential callback and insist on something usable. */ async function resolveKey(source, factory) { const answer = await source(); if (typeof answer === 'string' && answer.trim().length > 0) return answer; // The value is NOT quoted back. It is a credential when it is right, and it // is whatever the consumer's provider returned when it is wrong; a message // that printed it would be the one place this library leaks one. throw new Error(`${factory}: the \`apiKey\` callback returned ${describeKey(answer)}, and a key has to be a ` + 'non-empty string.\n' + ' The callback is called before every request, so this is a live credential failure, ' + 'not a configuration one — a token fetch that failed usually throws rather than returning ' + 'nothing.\n' + ` Fix: return the token, or throw from the callback so ${factory} can report why.`); } /** What came back, said without saying it. */ function describeKey(value) { if (typeof value === 'string') return 'an empty string'; if (value === null) return 'null'; if (value === undefined) return 'undefined'; return `a ${typeof value}`; } //# sourceMappingURL=googleGenAI.js.map