@kindgi/adapter-model-gemini 0.0.0-bootstrap.0 → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,237 @@
1
+ // SPDX-License-Identifier: Apache-2.0
2
+ // Copyright (C) 2026 Kindgi Inc.
3
+
4
+ import {
5
+ type GenerateContentConfig,
6
+ type GenerateContentParameters,
7
+ type GenerateContentResponse,
8
+ GoogleGenAI,
9
+ } from '@google/genai';
10
+ import type {
11
+ AdapterFactory,
12
+ ModelCallInput,
13
+ ModelCallResult,
14
+ ModelProvider,
15
+ ProviderMetadata,
16
+ } from '@kindgi/capabilities';
17
+
18
+ import { type GeminiModelInfo, computeCostUsd, toFrameworkUsage } from './cost.js';
19
+ import { fromGeminiResponse, toGeminiFunctions, toGeminiRequest } from './translate.js';
20
+
21
+ /** The adapter id servers register `geminiAdapterFactory` under. */
22
+ export const GEMINI_ADAPTER_ID = '@kindgi/adapter-model-gemini';
23
+
24
+ /** The slice of the SDK client the adapter calls. Tests pass a fake. */
25
+ export interface GeminiClient {
26
+ readonly models: {
27
+ generateContent(params: GenerateContentParameters): Promise<GenerateContentResponse>;
28
+ };
29
+ }
30
+
31
+ export interface GeminiProviderOptions {
32
+ /**
33
+ * Metadata surfaced to the router. `models[]` lists every model the
34
+ * caller expects to invoke through this connection; per-model `cost`
35
+ * takes Gemini's cached-token and long-context rates.
36
+ */
37
+ readonly metadata: Omit<ProviderMetadata, 'models'> & {
38
+ readonly models: readonly GeminiModelInfo[];
39
+ };
40
+ /** The Vertex AI project and location (`global`, or a region such as `us-central1`). */
41
+ readonly vertex: { readonly project: string; readonly location: string };
42
+ /**
43
+ * A service-account key (its JSON), resolved on every call. Absent:
44
+ * Google Application Default Credentials — `gcloud auth
45
+ * application-default login` on a laptop, the attached service account
46
+ * on Cloud Run and other Google Cloud runtimes.
47
+ */
48
+ readonly credentials?: () => string | Promise<string>;
49
+ /** An injected client, used as is (tests). `vertex` and `credentials` are then unused. */
50
+ readonly client?: GeminiClient;
51
+ }
52
+
53
+ const CLOUD_PLATFORM_SCOPE = 'https://www.googleapis.com/auth/cloud-platform';
54
+
55
+ /**
56
+ * A Gemini `ModelProvider` on Vertex AI. Non-streaming, tool-use enabled:
57
+ * one `generateContent` call per `invoke`.
58
+ */
59
+ export function createGeminiProvider(options: GeminiProviderOptions): ModelProvider {
60
+ const { metadata } = options;
61
+ checkVertexTarget(metadata.id, options.vertex);
62
+ const modelsByName = new Map<string, GeminiModelInfo>(
63
+ metadata.models.map((m) => [m.name, m] as const),
64
+ );
65
+
66
+ // One client per credential: reused while the resolved key stays the
67
+ // same, rebuilt when it rotates. `''` stands for ADC.
68
+ let cached: { readonly key: string; readonly client: GeminiClient } | undefined;
69
+ async function resolveClient(): Promise<GeminiClient> {
70
+ if (options.client !== undefined) return options.client;
71
+ const key = options.credentials !== undefined ? await options.credentials() : '';
72
+ if (cached?.key === key) return cached.client;
73
+ const client = new GoogleGenAI({
74
+ vertexai: true,
75
+ project: options.vertex.project,
76
+ location: options.vertex.location,
77
+ ...(key !== '' && {
78
+ googleAuthOptions: {
79
+ credentials: parseServiceAccountKey(key),
80
+ scopes: [CLOUD_PLATFORM_SCOPE],
81
+ },
82
+ }),
83
+ });
84
+ cached = { key, client };
85
+ return client;
86
+ }
87
+
88
+ return {
89
+ metadata,
90
+ async invoke(input: ModelCallInput): Promise<ModelCallResult> {
91
+ const model = modelsByName.get(input.model);
92
+ if (model === undefined) {
93
+ throw new Error(
94
+ `${GEMINI_ADAPTER_ID}: provider "${metadata.id}" does not expose model "${input.model}". ` +
95
+ `Available: ${[...modelsByName.keys()].join(', ') || '<none>'}.`,
96
+ );
97
+ }
98
+ const startedAt = Date.now();
99
+ const { systemInstruction, contents } = toGeminiRequest(input.messages);
100
+ const maxOutputTokens = input.maxOutputTokens ?? model.maxOutputTokens;
101
+ const config: GenerateContentConfig = {
102
+ ...(systemInstruction !== undefined && { systemInstruction }),
103
+ ...(input.tools !== undefined &&
104
+ input.tools.length > 0 && {
105
+ tools: [{ functionDeclarations: [...toGeminiFunctions(input.tools)] }],
106
+ }),
107
+ ...(input.structuredOutput !== undefined && {
108
+ responseMimeType: 'application/json',
109
+ responseJsonSchema: input.structuredOutput.schema,
110
+ }),
111
+ ...(input.temperature !== undefined && { temperature: input.temperature }),
112
+ ...(maxOutputTokens !== undefined && { maxOutputTokens }),
113
+ ...(input.abortSignal !== undefined && { abortSignal: input.abortSignal }),
114
+ };
115
+
116
+ const client = await resolveClient();
117
+ const response = await client.models.generateContent({
118
+ model: input.model,
119
+ contents: [...contents],
120
+ config,
121
+ });
122
+
123
+ const { message, finishReason } = fromGeminiResponse(response);
124
+ const usage = toFrameworkUsage(response.usageMetadata);
125
+ return {
126
+ message,
127
+ finishReason,
128
+ usage,
129
+ costUsd: computeCostUsd(usage, model.cost),
130
+ durationMs: Date.now() - startedAt,
131
+ provider: { id: metadata.id, model: input.model },
132
+ };
133
+ },
134
+ };
135
+ }
136
+
137
+ /**
138
+ * A service-account key's JSON, reduced to the fields a service-account
139
+ * key has. google-auth-library acts on a credential's `type`: an
140
+ * `external_account` fetches URLs it names (with its headers) and reads
141
+ * files it names, `token_uri` and `universe_domain` move the token
142
+ * exchange. A tenant writes this secret, so only `service_account` is
143
+ * accepted, and Google's own token endpoint is always used.
144
+ */
145
+ export function parseServiceAccountKey(key: string): Record<string, string> {
146
+ let parsed: unknown;
147
+ try {
148
+ parsed = JSON.parse(key);
149
+ } catch {
150
+ throw new Error(`${GEMINI_ADAPTER_ID}: the credential isn't a service-account key (not JSON).`);
151
+ }
152
+ if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) {
153
+ throw new Error(
154
+ `${GEMINI_ADAPTER_ID}: the credential isn't a service-account key (not an object).`,
155
+ );
156
+ }
157
+ const fields = parsed as Record<string, unknown>;
158
+ if (fields.type !== 'service_account') {
159
+ throw new Error(
160
+ `${GEMINI_ADAPTER_ID}: the credential must be a service-account key ("type": "service_account"); other credential types aren't accepted.`,
161
+ );
162
+ }
163
+ if (typeof fields.client_email !== 'string' || typeof fields.private_key !== 'string') {
164
+ throw new Error(
165
+ `${GEMINI_ADAPTER_ID}: the credential isn't a service-account key (no client_email / private_key).`,
166
+ );
167
+ }
168
+ const reduced: Record<string, string> = {
169
+ type: 'service_account',
170
+ client_email: fields.client_email,
171
+ private_key: fields.private_key,
172
+ };
173
+ for (const optional of ['project_id', 'private_key_id', 'client_id'] as const) {
174
+ const value = fields[optional];
175
+ if (typeof value === 'string') reduced[optional] = value;
176
+ }
177
+ return reduced;
178
+ }
179
+
180
+ /**
181
+ * The adapter factory servers register under `GEMINI_ADAPTER_ID`. A
182
+ * provider registered with it carries:
183
+ *
184
+ * - `adapter_config.project` — the Vertex AI project (required);
185
+ * - `metadata.region` — the Vertex location (`global`, `us-central1`,
186
+ * `northamerica-northeast1`, …); `'unspecified'` means `global`;
187
+ * - `secret_ref` (optional) — a service-account key. Without one the
188
+ * adapter uses Application Default Credentials.
189
+ */
190
+ export const geminiAdapterFactory: AdapterFactory = (input) =>
191
+ createGeminiProvider({
192
+ metadata: input.metadata as GeminiProviderOptions['metadata'],
193
+ vertex: vertexTarget(input),
194
+ ...(input.resolveApiKey !== undefined && { credentials: input.resolveApiKey }),
195
+ });
196
+
197
+ /** The Vertex project and location a registration names (see `geminiAdapterFactory`). */
198
+ export function vertexTarget(
199
+ input: Parameters<AdapterFactory>[0],
200
+ ): GeminiProviderOptions['vertex'] {
201
+ const project = input.config?.project;
202
+ if (typeof project !== 'string' || project.length === 0) {
203
+ throw new Error(
204
+ `${GEMINI_ADAPTER_ID}: provider "${input.metadata.id}" needs adapter_config.project (the Vertex AI project).`,
205
+ );
206
+ }
207
+ const target = {
208
+ project,
209
+ location: input.metadata.region === 'unspecified' ? 'global' : input.metadata.region,
210
+ };
211
+ checkVertexTarget(input.metadata.id, target);
212
+ return target;
213
+ }
214
+
215
+ /**
216
+ * The SDK puts the project in the request path and builds the hostname
217
+ * from the location (`https://<location>-aiplatform.googleapis.com/`):
218
+ * anything but a project id and one DNS label could move the request,
219
+ * with the server's credentials, to another host.
220
+ */
221
+ function checkVertexTarget(providerId: string, vertex: GeminiProviderOptions['vertex']): void {
222
+ if (!VERTEX_PROJECT.test(vertex.project)) {
223
+ throw new Error(
224
+ `${GEMINI_ADAPTER_ID}: provider "${providerId}" adapter_config.project must be a Google Cloud project id or number.`,
225
+ );
226
+ }
227
+ if (!VERTEX_LOCATION.test(vertex.location)) {
228
+ throw new Error(
229
+ `${GEMINI_ADAPTER_ID}: provider "${providerId}" metadata.region must be a Vertex AI location (one DNS label, e.g. us-central1).`,
230
+ );
231
+ }
232
+ }
233
+
234
+ /** A Google Cloud project id (6–30: lowercase, digits, hyphens) or project number. */
235
+ const VERTEX_PROJECT = /^(?:[a-z][a-z0-9-]{4,28}[a-z0-9]|[0-9]{1,20})$/;
236
+ /** A Vertex AI location: one DNS label. */
237
+ const VERTEX_LOCATION = /^[a-z][a-z0-9-]{0,62}$/;
@@ -0,0 +1,215 @@
1
+ // SPDX-License-Identifier: Apache-2.0
2
+ // Copyright (C) 2026 Kindgi Inc.
3
+
4
+ import { randomUUID } from 'node:crypto';
5
+
6
+ import type {
7
+ Content,
8
+ FunctionCall,
9
+ FunctionDeclaration,
10
+ GenerateContentResponse,
11
+ Part,
12
+ } from '@google/genai';
13
+ import type {
14
+ ModelCallResult,
15
+ ModelMessage,
16
+ ModelToolCall,
17
+ ModelToolDefinition,
18
+ } from '@kindgi/capabilities';
19
+
20
+ /**
21
+ * Gemini function names: a letter or `_` first, then letters, digits,
22
+ * `_ . : -`, at most 64 characters. The framework's `<pack>.<tool>` ids
23
+ * fit as they are, so names go over the wire unchanged.
24
+ */
25
+ const FUNCTION_NAME = /^[A-Za-z_][A-Za-z0-9_.:-]{0,63}$/;
26
+
27
+ export function checkFunctionName(name: string): string {
28
+ if (!FUNCTION_NAME.test(name)) {
29
+ throw new Error(
30
+ `@kindgi/adapter-model-gemini: tool "${name}" isn't a valid Gemini function name (a letter or _ first, then letters, digits, _ . : -, at most 64 characters).`,
31
+ );
32
+ }
33
+ return name;
34
+ }
35
+
36
+ /**
37
+ * The framework's message trail as a Gemini request:
38
+ *
39
+ * - `system` messages lift into `systemInstruction` (several join with
40
+ * a blank line, in order);
41
+ * - `user` messages become `user` turns; `assistant` messages become
42
+ * `model` turns — text, then one `functionCall` part per tool call,
43
+ * carrying the call's thought signature back;
44
+ * - `tool` messages become `functionResponse` parts in a `user` turn.
45
+ * Gemini names the function it answers, so the name comes from the
46
+ * assistant tool call with the same id. The tool's output goes under
47
+ * `response.output` (parsed when it is JSON).
48
+ *
49
+ * Consecutive turns of the same role merge into one `Content`.
50
+ */
51
+ export function toGeminiRequest(messages: readonly ModelMessage[]): {
52
+ readonly systemInstruction: string | undefined;
53
+ readonly contents: readonly Content[];
54
+ } {
55
+ const systemParts: string[] = [];
56
+ const contents: Content[] = [];
57
+ const callNames = new Map<string, string>();
58
+
59
+ for (const msg of messages) {
60
+ if (msg.role === 'system') {
61
+ if (msg.content.length > 0) systemParts.push(msg.content);
62
+ } else if (msg.role === 'user') {
63
+ append(contents, 'user', [{ text: msg.content }]);
64
+ } else if (msg.role === 'assistant') {
65
+ const parts = modelParts(msg, callNames);
66
+ // An empty model turn is invalid; the agent loop never produces one.
67
+ if (parts.length > 0) append(contents, 'model', parts);
68
+ } else {
69
+ append(contents, 'user', [functionResponsePart(msg, callNames)]);
70
+ }
71
+ }
72
+
73
+ return {
74
+ systemInstruction: systemParts.length > 0 ? systemParts.join('\n\n') : undefined,
75
+ contents,
76
+ };
77
+ }
78
+
79
+ /** An assistant message's parts: its text, then a `functionCall` per tool call. */
80
+ function modelParts(msg: ModelMessage, callNames: Map<string, string>): Part[] {
81
+ const parts: Part[] = msg.content.length > 0 ? [{ text: msg.content }] : [];
82
+ for (const call of msg.toolCalls ?? []) {
83
+ callNames.set(call.id, call.name);
84
+ parts.push({
85
+ functionCall: {
86
+ id: call.id,
87
+ name: checkFunctionName(call.name),
88
+ args: { ...call.arguments },
89
+ },
90
+ ...(call.signature !== undefined && { thoughtSignature: call.signature }),
91
+ });
92
+ }
93
+ return parts;
94
+ }
95
+
96
+ /** A tool result, named after the call it answers. */
97
+ function functionResponsePart(msg: ModelMessage, callNames: ReadonlyMap<string, string>): Part {
98
+ const id = msg.toolCallId ?? '';
99
+ const name = callNames.get(id);
100
+ if (name === undefined) {
101
+ throw new Error(
102
+ `@kindgi/adapter-model-gemini: a tool result answers call "${id}", which no earlier assistant turn made.`,
103
+ );
104
+ }
105
+ return { functionResponse: { id, name, response: { output: parseOutput(msg.content) } } };
106
+ }
107
+
108
+ function append(contents: Content[], role: 'user' | 'model', parts: readonly Part[]): void {
109
+ const last = contents[contents.length - 1];
110
+ if (last !== undefined && last.role === role) {
111
+ last.parts = [...(last.parts ?? []), ...parts];
112
+ return;
113
+ }
114
+ contents.push({ role, parts: [...parts] });
115
+ }
116
+
117
+ /** A tool's output: its JSON value when it is JSON, else the text. */
118
+ function parseOutput(content: string): unknown {
119
+ try {
120
+ return JSON.parse(content);
121
+ } catch {
122
+ return content;
123
+ }
124
+ }
125
+
126
+ /**
127
+ * Tool definitions as Gemini function declarations. The input schema is
128
+ * full JSON Schema, which `parametersJsonSchema` takes as it is.
129
+ */
130
+ export function toGeminiFunctions(
131
+ tools: readonly ModelToolDefinition[],
132
+ ): readonly FunctionDeclaration[] {
133
+ return tools.map((t) => ({
134
+ name: checkFunctionName(t.name),
135
+ description: t.description,
136
+ parametersJsonSchema: t.inputSchema,
137
+ }));
138
+ }
139
+
140
+ /**
141
+ * A Gemini response as the framework's assistant message: the text of
142
+ * the first candidate (thinking parts left out), and a tool call per
143
+ * `functionCall` part, with its thought signature. Gemini may omit the
144
+ * call id; one is generated then, so the tool result can refer to it.
145
+ */
146
+ export function fromGeminiResponse(response: GenerateContentResponse): {
147
+ readonly message: ModelMessage;
148
+ readonly finishReason: ModelCallResult['finishReason'];
149
+ } {
150
+ const candidate = response.candidates?.[0];
151
+ if (candidate === undefined) {
152
+ // No candidate: the prompt itself was blocked.
153
+ const blocked = response.promptFeedback?.blockReason !== undefined;
154
+ return {
155
+ message: { role: 'assistant', content: '' },
156
+ finishReason: blocked ? 'content-filter' : 'error',
157
+ };
158
+ }
159
+
160
+ const parts = candidate.content?.parts ?? [];
161
+ const text = parts
162
+ .filter((part) => part.functionCall === undefined && part.thought !== true)
163
+ .map((part) => part.text ?? '');
164
+ const toolCalls = parts.flatMap((part) =>
165
+ part.functionCall !== undefined ? [toToolCall(part.functionCall, part.thoughtSignature)] : [],
166
+ );
167
+
168
+ return {
169
+ message: {
170
+ role: 'assistant',
171
+ content: text.join(''),
172
+ ...(toolCalls.length > 0 && { toolCalls }),
173
+ },
174
+ finishReason: toolCalls.length > 0 ? 'tool-use' : mapFinishReason(candidate.finishReason),
175
+ };
176
+ }
177
+
178
+ function toToolCall(call: FunctionCall, signature: string | undefined): ModelToolCall {
179
+ return {
180
+ id: call.id ?? `gemini-call-${randomUUID()}`,
181
+ name: call.name ?? '',
182
+ arguments: (call.args ?? {}) as Readonly<Record<string, unknown>>,
183
+ ...(signature !== undefined && { signature }),
184
+ };
185
+ }
186
+
187
+ const FILTERED = new Set([
188
+ 'SAFETY',
189
+ 'RECITATION',
190
+ 'BLOCKLIST',
191
+ 'PROHIBITED_CONTENT',
192
+ 'SPII',
193
+ 'IMAGE_SAFETY',
194
+ 'IMAGE_PROHIBITED_CONTENT',
195
+ 'IMAGE_RECITATION',
196
+ ]);
197
+
198
+ /**
199
+ * Gemini's `finishReason` as the framework's:
200
+ * - `STOP`, unspecified or absent → `stop` (a response with function
201
+ * calls is `tool-use` before this is consulted);
202
+ * - `MAX_TOKENS` → `length`;
203
+ * - the safety, recitation, blocklist and sensitive-data stops →
204
+ * `content-filter`;
205
+ * - anything else (a malformed or unexpected function call, too many
206
+ * tool calls, an unsupported language, `OTHER`) → `error`.
207
+ */
208
+ export function mapFinishReason(reason: string | undefined): ModelCallResult['finishReason'] {
209
+ if (reason === undefined || reason === 'STOP' || reason === 'FINISH_REASON_UNSPECIFIED') {
210
+ return 'stop';
211
+ }
212
+ if (reason === 'MAX_TOKENS') return 'length';
213
+ if (FILTERED.has(reason)) return 'content-filter';
214
+ return 'error';
215
+ }