@kindgi/adapter-model-gemini 0.0.0-stage → 0.1.1
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.
- package/LICENSE +201 -0
- package/README.md +101 -2
- package/dist/cost.d.ts +42 -0
- package/dist/cost.d.ts.map +1 -0
- package/dist/cost.js +40 -0
- package/dist/cost.js.map +1 -0
- package/dist/index.d.ts +7 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +6 -0
- package/dist/index.js.map +1 -0
- package/dist/provider.d.ts +63 -0
- package/dist/provider.d.ts.map +1 -0
- package/dist/provider.js +168 -0
- package/dist/provider.js.map +1 -0
- package/dist/translate.d.ts +49 -0
- package/dist/translate.d.ts.map +1 -0
- package/dist/translate.js +179 -0
- package/dist/translate.js.map +1 -0
- package/package.json +48 -3
- package/src/cost.ts +73 -0
- package/src/index.ts +20 -0
- package/src/provider.ts +237 -0
- package/src/translate.ts +215 -0
package/src/provider.ts
ADDED
|
@@ -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}$/;
|
package/src/translate.ts
ADDED
|
@@ -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
|
+
}
|