/**
* Copyright 2025 Google LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import * as assert from 'assert';
import { afterEach, beforeEach, describe, it } from 'node:test';
import * as sinon from 'sinon';
import { TextEncoder } from 'util';
import {
FinishReason,
HarmCategory,
HarmProbability,
} from '../../src/common/types.js';
import { getGenkitClientHeader } from '../../src/common/utils.js';
import {
TEST_ONLY,
createInteraction,
embedContent,
generateContent,
generateContentStream,
getGoogleAIUrl,
listModels,
veoCheckOperation,
veoPredict,
} from '../../src/googleai/client.js';
import {
ClientOptions,
CreateInteractionRequest,
EmbedContentRequest,
EmbedContentResponse,
GenerateContentRequest,
GenerateContentResponse,
GenerateContentStreamResult,
Model,
VeoOperation,
VeoPredictRequest,
} from '../../src/googleai/types.js';
const { getAbortSignal } = TEST_ONLY;
describe('Google AI Client', () => {
let fetchSpy: sinon.SinonStub;
let runInNewSpanSpy: sinon.SinonStub;
const apiKey = 'test-api-key';
const defaultBaseUrl = 'https://generativelanguage.googleapis.com';
const defaultApiVersion = 'v1beta';
beforeEach(() => {
// In case a previous test failed and left stubs hanging, ensure a clean state
sinon.restore();
fetchSpy = sinon.stub(global, 'fetch');
// We stub runInNewSpan via tracingHooks instead of importing the tracing
// module directly. This avoids ESM immutable binding issues where Sinon
// throws "descriptor for property 'runInNewSpan' is non-configurable".
runInNewSpanSpy = sinon
.stub(TEST_ONLY.tracingHooks, 'runInNewSpan')
.callsFake((async (...args: any[]) => {
// runInNewSpan takes either 2 or 3 arguments depending on the overload.
// We pop the last argument, which is always the callback function.
const fn = args.pop();
const options = args[0];
// Pass options.metadata to the callback so the caller can mutate it,
// allowing us to assert on input/output fields in our tests.
return await fn(options.metadata);
}) as any);
});
afterEach(() => {
sinon.restore();
});
function mockFetchResponse(
body: any,
ok = true,
status = 200,
statusText = 'OK',
contentType = 'application/json'
) {
const bodyString =
body === null || body === undefined
? ''
: contentType === 'application/json'
? JSON.stringify(body)
: String(body);
const response = new Response(bodyString, {
status: status,
statusText: statusText,
headers: { 'Content-Type': contentType },
});
fetchSpy.resolves(response);
}
function createMockStream(chunks: string[]): Response {
const stream = new ReadableStream({
start(controller) {
for (const chunk of chunks) {
controller.enqueue(new TextEncoder().encode(chunk));
}
controller.close();
},
});
return new Response(stream, {
headers: { 'Content-Type': 'application/json' },
});
}
describe('getGoogleAIUrl', () => {
it('should build basic URL', () => {
const url = getGoogleAIUrl({ resourcePath: 'models' });
assert.strictEqual(url, `${defaultBaseUrl}/${defaultApiVersion}/models`);
});
it('should build URL with resourceMethod', () => {
const url = getGoogleAIUrl({
resourcePath: 'models/gemini-2.5-pro',
resourceMethod: 'generateContent',
});
assert.strictEqual(
url,
`${defaultBaseUrl}/${defaultApiVersion}/models/gemini-2.5-pro:generateContent`
);
});
it('should build URL with queryParams', () => {
const url = getGoogleAIUrl({
resourcePath: 'models',
queryParams: 'pageSize=10',
});
assert.strictEqual(
url,
`${defaultBaseUrl}/${defaultApiVersion}/models?pageSize=10`
);
});
it('should add alt=sse for streamGenerateContent', () => {
const url = getGoogleAIUrl({
resourcePath: 'models/gemini-2.5-flash',
resourceMethod: 'streamGenerateContent',
});
assert.strictEqual(
url,
`${defaultBaseUrl}/${defaultApiVersion}/models/gemini-2.5-flash:streamGenerateContent?alt=sse`
);
});
it('should add alt=sse for streamGenerateContent with other queryParams', () => {
const url = getGoogleAIUrl({
resourcePath: 'models/gemini-2.5-flash',
resourceMethod: 'streamGenerateContent',
queryParams: 'test=123',
});
assert.strictEqual(
url,
`${defaultBaseUrl}/${defaultApiVersion}/models/gemini-2.5-flash:streamGenerateContent?test=123&alt=sse`
);
});
it('should use custom apiVersion and baseUrl from RequestOptions', () => {
const clientOptions: ClientOptions = {
apiVersion: 'v1',
baseUrl: 'https://custom.googleapis.com',
};
const url = getGoogleAIUrl({ resourcePath: 'models', clientOptions });
assert.strictEqual(url, 'https://custom.googleapis.com/v1/models');
});
});
describe('listModels', () => {
it('should return a list of models', async () => {
const mockModels: Model[] = [
{ name: 'models/gemini-2.5-pro' } as Model,
{ name: 'models/gemini-2.5-flash' } as Model,
];
mockFetchResponse({ models: mockModels });
const result = await listModels(apiKey);
assert.deepStrictEqual(result, mockModels);
const expectedUrl =
'https://generativelanguage.googleapis.com/v1beta/models?pageSize=1000';
sinon.assert.calledOnceWithExactly(fetchSpy, expectedUrl, {
method: 'GET',
headers: {
'Content-Type': 'application/json',
'x-goog-api-key': apiKey,
'x-goog-api-client': getGenkitClientHeader(),
},
redirect: 'manual',
});
});
it('should throw an error if fetch fails with JSON error', async () => {
const errorResponse = { error: { message: 'Internal Error' } };
mockFetchResponse(errorResponse, false, 500, 'Internal Server Error');
await assert.rejects(listModels(apiKey), (err: any) => {
assert.strictEqual(err.name, 'GenkitError');
assert.strictEqual(err.status, 'INTERNAL');
assert.match(
err.message,
/Error fetching from .* \[500 Internal Server Error\] Internal Error/
);
return true;
});
});
it('should throw an error if fetch fails with non-JSON error', async () => {
mockFetchResponse(
'
Server Error
',
false,
500,
'Internal Server Error',
'text/html'
);
await assert.rejects(listModels(apiKey), (err: any) => {
assert.strictEqual(err.name, 'GenkitError');
assert.strictEqual(err.status, 'INTERNAL');
assert.match(
err.message,
/Error fetching from .* \[500 Internal Server Error\] Server Error<\/h1><\/body><\/html>/
);
return true;
});
});
it('should throw an error if fetch fails with empty response body', async () => {
mockFetchResponse(null, false, 502, 'Bad Gateway');
await assert.rejects(listModels(apiKey), (err: any) => {
assert.strictEqual(err.name, 'GenkitError');
assert.strictEqual(err.status, 'UNKNOWN');
assert.match(err.message, /Error fetching from .* \[502 Bad Gateway\]/);
return true;
});
});
it('should throw a resource exhausted error on 429', async () => {
const errorResponse = { error: { message: 'Too many requests' } };
mockFetchResponse(errorResponse, false, 429, 'Too Many Requests');
await assert.rejects(listModels(apiKey), (err: any) => {
assert.strictEqual(err.name, 'GenkitError');
assert.strictEqual(err.status, 'RESOURCE_EXHAUSTED');
assert.match(
err.message,
/Error fetching from .* \[429 Too Many Requests\] Too many requests/
);
return true;
});
});
it('should throw an error on network failure', async () => {
fetchSpy.rejects(new Error('Network connection failed'));
await assert.rejects(
listModels(apiKey),
/Failed to fetch from .* Network connection failed/
);
});
it('does not apply Interactions error-code mapping to non-Interactions URLs', async () => {
// `safety` would map to FAILED_PRECONDITION on the Interactions path.
mockFetchResponse(
{ error: { code: 'safety', message: 'blocked' } },
false,
400,
'Bad Request'
);
await assert.rejects(listModels(apiKey), (err: any) => {
assert.strictEqual(err.status, 'INVALID_ARGUMENT');
return true;
});
});
it('should include custom headers', async () => {
mockFetchResponse({ models: [] });
const clientOptions: ClientOptions = {
customHeaders: { 'X-Custom-Header': 'test' },
};
await listModels(apiKey, clientOptions);
sinon.assert.calledOnce(fetchSpy);
const headers = fetchSpy.firstCall.args[1].headers;
assert.strictEqual(headers['X-Custom-Header'], 'test');
assert.strictEqual(headers['x-goog-api-key'], apiKey);
});
});
describe('createInteraction headers', () => {
const request: CreateInteractionRequest = {
model: 'gemini-flash-latest',
input: 'hi',
};
it('sends the Api-Revision header', async () => {
mockFetchResponse({ status: 'completed', steps: [] });
await createInteraction(apiKey, request);
const headers = fetchSpy.lastCall.args[1].headers;
assert.strictEqual(headers['Api-Revision'], '2026-05-20');
});
it('does not let custom headers in any case change protected headers', async () => {
mockFetchResponse({ status: 'completed', steps: [] });
await createInteraction(apiKey, request, {
customHeaders: {
'api-revision': '2025-01-01',
'X-Goog-Api-Key': 'other-key',
'x-custom': 'a',
},
});
// fetch merges case variants of a header, so check the merged value.
const headers = new Headers(fetchSpy.lastCall.args[1].headers);
assert.strictEqual(headers.get('api-revision'), '2026-05-20');
assert.strictEqual(headers.get('x-goog-api-key'), apiKey);
assert.strictEqual(headers.get('x-custom'), 'a');
});
});
describe('Interactions API HTTP errors', () => {
const request = { model: 'gemini-flash-latest', input: 'hi' } as any;
async function expectStatus(
body: any,
httpStatus: number,
statusText: string,
expected: string
) {
mockFetchResponse(body, false, httpStatus, statusText);
await assert.rejects(createInteraction(apiKey, request), (err: any) => {
assert.strictEqual(err.name, 'GenkitError');
assert.strictEqual(err.status, expected);
return true;
});
}
it('maps a pre-execution safety block (400 safety) to FAILED_PRECONDITION', async () => {
await expectStatus(
{
error: {
code: 'safety',
message:
'Request blocked due to safety violations (harmful content). Please modify your input and retry.',
},
},
400,
'Bad Request',
'FAILED_PRECONDITION'
);
});
it('maps 404 model_not_found to NOT_FOUND', async () => {
await expectStatus(
{ error: { code: 'model_not_found', message: 'no such model' } },
404,
'Not Found',
'NOT_FOUND'
);
});
it('maps 429 quota_exceeded to RESOURCE_EXHAUSTED', async () => {
await expectStatus(
{ error: { code: 'quota_exceeded', message: 'quota' } },
429,
'Too Many Requests',
'RESOURCE_EXHAUSTED'
);
});
it('maps 401 authentication to UNAUTHENTICATED', async () => {
await expectStatus(
{ error: { code: 'authentication', message: 'bad key' } },
401,
'Unauthorized',
'UNAUTHENTICATED'
);
});
it('falls back to the HTTP status for an unrecognized code', async () => {
await expectStatus(
{ error: { code: 'something_new', message: 'huh' } },
403,
'Forbidden',
'PERMISSION_DENIED'
);
});
it('detects Interactions URLs by path segment', () => {
const { isInteractionUrl } = TEST_ONLY;
assert.strictEqual(
isInteractionUrl(`${defaultBaseUrl}/v1beta/interactions`),
true
);
assert.strictEqual(
isInteractionUrl(`${defaultBaseUrl}/v1beta/interactions/abc/cancel`),
true
);
assert.strictEqual(
isInteractionUrl(
`${defaultBaseUrl}/v1beta/models/gemini-2.5-flash:generateContent`
),
false
);
assert.strictEqual(
isInteractionUrl(`${defaultBaseUrl}/v1beta/models?q=interactions`),
false
);
});
});
describe('generateContent', () => {
const model = 'gemini-flash-latest';
const request: GenerateContentRequest = {
contents: [{ role: 'user', parts: [{ text: 'hello' }] }],
};
it('should return GenerateContentResponse', async () => {
const mockResponse: GenerateContentResponse = {
candidates: [
{ index: 0, content: { role: 'model', parts: [{ text: 'world' }] } },
],
};
mockFetchResponse(mockResponse);
const result = await generateContent(apiKey, model, request);
assert.deepStrictEqual(result, mockResponse);
const expectedUrl = `https://generativelanguage.googleapis.com/v1beta/models/${model}:generateContent`;
sinon.assert.calledOnceWithExactly(fetchSpy, expectedUrl, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'x-goog-api-key': apiKey,
'x-goog-api-client': getGenkitClientHeader(),
},
redirect: 'manual',
body: JSON.stringify(request),
});
// Should not trace by default
sinon.assert.notCalled(runInNewSpanSpy);
});
it('should trace generateContent if experimental_debugTraces is true', async () => {
const mockResponse: GenerateContentResponse = {
candidates: [
{ index: 0, content: { role: 'model', parts: [{ text: 'world' }] } },
],
};
mockFetchResponse(mockResponse);
const clientOptions: ClientOptions = { experimental_debugTraces: true };
const result = await generateContent(
apiKey,
model,
request,
clientOptions
);
assert.deepStrictEqual(result, mockResponse);
sinon.assert.calledOnce(runInNewSpanSpy);
const traceMetadata = runInNewSpanSpy.firstCall.args[0].metadata;
assert.strictEqual(traceMetadata.name, 'httpRequest');
assert.deepStrictEqual(traceMetadata.input, {
apiEndpoint: `https://generativelanguage.googleapis.com/v1beta/models/${model}:generateContent`,
request: request,
model: model,
headers: {
'Content-Type': 'application/json',
'x-goog-api-key': ' (12 characters)', // "test-api-key"
'x-goog-api-client': getGenkitClientHeader(),
},
});
assert.deepStrictEqual(traceMetadata.output, mockResponse);
});
it('should throw on API error with JSON body', async () => {
const errorResponse = { error: { message: 'Invalid Request' } };
mockFetchResponse(errorResponse, false, 400, 'Bad Request');
await assert.rejects(
generateContent(apiKey, model, request),
(err: any) => {
assert.strictEqual(err.name, 'GenkitError');
assert.strictEqual(err.status, 'INVALID_ARGUMENT');
assert.match(
err.message,
/Error fetching from .* \[400 Bad Request\] Invalid Request/
);
return true;
}
);
});
it('should throw on API error with non-JSON body', async () => {
mockFetchResponse('Bad Request', false, 400, 'Bad Request', 'text/plain');
await assert.rejects(
generateContent(apiKey, model, request),
(err: any) => {
assert.strictEqual(err.name, 'GenkitError');
assert.strictEqual(err.status, 'INVALID_ARGUMENT');
assert.match(
err.message,
/Error fetching from .* \[400 Bad Request\] Bad Request/
);
return true;
}
);
});
it('should throw on network failure', async () => {
fetchSpy.rejects(new TypeError('Failed to fetch'));
await assert.rejects(
generateContent(apiKey, model, request),
/Failed to fetch from .* Failed to fetch/
);
});
});
describe('embedContent', () => {
const model = 'text-embedding-005';
const request: EmbedContentRequest = {
content: { role: 'user', parts: [{ text: 'test content' }] },
};
it('should return EmbedContentResponse', async () => {
const mockResponse: EmbedContentResponse = {
embedding: { values: [0.1, 0.2, 0.3] },
};
mockFetchResponse(mockResponse);
const result = await embedContent(apiKey, model, request);
assert.deepStrictEqual(result, mockResponse);
const expectedUrl = `https://generativelanguage.googleapis.com/v1beta/models/${model}:embedContent`;
sinon.assert.calledOnceWithExactly(fetchSpy, expectedUrl, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'x-goog-api-key': apiKey,
'x-goog-api-client': getGenkitClientHeader(),
},
redirect: 'manual',
body: JSON.stringify(request),
});
sinon.assert.notCalled(runInNewSpanSpy);
});
it('should trace embedContent if experimental_debugTraces is true', async () => {
const mockResponse: EmbedContentResponse = {
embedding: { values: [0.1, 0.2, 0.3] },
};
mockFetchResponse(mockResponse);
const clientOptions: ClientOptions = { experimental_debugTraces: true };
const result = await embedContent(apiKey, model, request, clientOptions);
assert.deepStrictEqual(result, mockResponse);
sinon.assert.calledOnce(runInNewSpanSpy);
const traceMetadata = runInNewSpanSpy.firstCall.args[0].metadata;
assert.strictEqual(traceMetadata.name, 'httpRequest');
assert.deepStrictEqual(traceMetadata.input, {
apiEndpoint: `https://generativelanguage.googleapis.com/v1beta/models/${model}:embedContent`,
request: request,
model: model,
headers: {
'Content-Type': 'application/json',
'x-goog-api-key': ' (12 characters)',
'x-goog-api-client': getGenkitClientHeader(),
},
});
assert.deepStrictEqual(traceMetadata.output, mockResponse);
});
it('should throw on API error with JSON body', async () => {
const errorResponse = { error: { message: 'Embedding failed' } };
mockFetchResponse(errorResponse, false, 500, 'Internal Server Error');
await assert.rejects(embedContent(apiKey, model, request), (err: any) => {
assert.strictEqual(err.name, 'GenkitError');
assert.strictEqual(err.status, 'INTERNAL');
assert.match(
err.message,
/Error fetching from .* \[500 Internal Server Error\] Embedding failed/
);
return true;
});
});
it('should throw on API error with non-JSON body', async () => {
mockFetchResponse(
'Internal Server Error',
false,
500,
'Internal Server Error',
'text/plain'
);
await assert.rejects(embedContent(apiKey, model, request), (err: any) => {
assert.strictEqual(err.name, 'GenkitError');
assert.strictEqual(err.status, 'INTERNAL');
assert.match(
err.message,
/Error fetching from .* \[500 Internal Server Error\] Internal Server Error/
);
return true;
});
});
it('should throw on network failure', async () => {
fetchSpy.rejects(new TypeError('Network error'));
await assert.rejects(
embedContent(apiKey, model, request),
/Failed to fetch from .* Network error/
);
});
});
describe('generateContentStream', () => {
const model = 'gemini-2.5-flash';
const defaultRequest: GenerateContentRequest = {
contents: [{ role: 'user', parts: [{ text: 'stream test' }] }],
};
it('should trace generateContentStream if experimental_debugTraces is true', async () => {
const chunks = [
'data: {"candidates": [{"index": 0, "content": {"role": "model", "parts": [{"text": "Hello "}]}}]}\n\n',
];
fetchSpy.resolves(createMockStream(chunks));
const clientOptions: ClientOptions = { experimental_debugTraces: true };
const result = await generateContentStream(
apiKey,
model,
defaultRequest,
clientOptions
);
const streamResults: GenerateContentResponse[] = [];
for await (const item of result.stream) {
streamResults.push(item);
}
sinon.assert.calledOnce(runInNewSpanSpy);
const traceMetadata = runInNewSpanSpy.firstCall.args[0].metadata;
assert.strictEqual(traceMetadata.name, 'httpRequest');
assert.deepStrictEqual(traceMetadata.input, {
apiEndpoint: `https://generativelanguage.googleapis.com/v1beta/models/${model}:streamGenerateContent?alt=sse`,
request: defaultRequest,
model: model,
headers: {
'Content-Type': 'application/json',
'x-goog-api-key': ' (12 characters)',
'x-goog-api-client': getGenkitClientHeader(),
},
});
assert.strictEqual(traceMetadata.output, '[Streaming Response]');
});
it('should process stream and return stream and aggregated response', async () => {
const chunks = [
'data: {"candidates": [{"index": 0, "content": {"role": "model", "parts": [{"text": "Hello "}]}}]}\n\n',
'data: {"candidates": [{"index": 0, "content": {"role": "model", "parts": [{"text": "World!"}]}}], "usageMetadata": {"totalTokenCount": 10}}\n\n',
];
fetchSpy.resolves(createMockStream(chunks));
const result: GenerateContentStreamResult = await generateContentStream(
apiKey,
model,
defaultRequest
);
const streamResults: GenerateContentResponse[] = [];
for await (const item of result.stream) {
streamResults.push(item);
}
assert.deepStrictEqual(streamResults, [
{
candidates: [
{
index: 0,
content: { role: 'model', parts: [{ text: 'Hello ' }] },
},
],
},
{
candidates: [
{
index: 0,
content: { role: 'model', parts: [{ text: 'World!' }] },
},
],
usageMetadata: { totalTokenCount: 10 },
},
]);
const aggregated = await result.response;
assert.deepStrictEqual(aggregated, {
candidates: [
{
index: 0,
content: {
role: 'model',
parts: [{ text: 'Hello ' }, { text: 'World!' }],
},
},
],
usageMetadata: { totalTokenCount: 10 },
});
const expectedUrl = `https://generativelanguage.googleapis.com/v1beta/models/${model}:streamGenerateContent?alt=sse`;
sinon.assert.calledOnceWithExactly(fetchSpy, expectedUrl, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'x-goog-api-key': apiKey,
'x-goog-api-client': getGenkitClientHeader(),
},
redirect: 'manual',
body: JSON.stringify(defaultRequest),
});
});
it('should aggregate parts for multiple candidates', async () => {
const chunks = [
'data: {"candidates": [{"index": 0, "content": {"role": "model", "parts": [{"text": "C0 A"}]}}, {"index": 1, "content": {"role": "model", "parts": [{"text": "C1 A"}]}}]}\n\n',
'data: {"candidates": [{"index": 0, "content": {"role": "model", "parts": [{"text": " C0 B"}]}}, {"index": 1, "content": {"role": "model", "parts": [{"text": " C1 B"}]}}]}\n\n',
];
fetchSpy.resolves(createMockStream(chunks));
const result = await generateContentStream(apiKey, model, defaultRequest);
const aggregated = await result.response;
assert.strictEqual(aggregated.candidates?.length, 2);
const sortedCandidates = aggregated.candidates!.sort(
(a, b) => a.index - b.index
);
assert.deepStrictEqual(sortedCandidates[0], {
index: 0,
content: {
role: 'model',
parts: [{ text: 'C0 A' }, { text: ' C0 B' }],
},
});
assert.deepStrictEqual(sortedCandidates[1], {
index: 1,
content: {
role: 'model',
parts: [{ text: 'C1 A' }, { text: ' C1 B' }],
},
});
});
it('should use latest metadata from chunks for each candidate', async () => {
const chunks = [
'data: {"candidates": [{"index": 0, "content": {"role": "model", "parts": [{"text": "A"}]}, "finishReason": "STOP"}, {"index": 1, "content": {"role": "model", "parts": [{"text": "C1 "}]}}]}\n\n',
'data: {"candidates": [{"index": 0, "content": {"role": "model", "parts": [{"text": "B"}]}, "finishReason": "MAX_TOKENS", "safetyRatings": [{"category": "HARM_CATEGORY_DANGEROUS_CONTENT", "probability": "LOW"}]}, {"index": 1, "content": {"role": "model", "parts": [{"text": "D1"}]}, "finishReason": "STOP" }], "usageMetadata": {"totalTokenCount": 20}}\n\n',
];
fetchSpy.resolves(createMockStream(chunks));
const result = await generateContentStream(apiKey, model, defaultRequest);
const aggregated = await result.response;
assert.strictEqual(aggregated.candidates?.length, 2);
const sortedCandidates = aggregated.candidates!.sort(
(a, b) => a.index - b.index
);
const cand0 = sortedCandidates[0];
assert.strictEqual(cand0.index, 0);
assert.strictEqual(cand0.finishReason, FinishReason.MAX_TOKENS);
assert.deepStrictEqual(cand0.safetyRatings, [
{
category: HarmCategory.HARM_CATEGORY_DANGEROUS_CONTENT,
probability: HarmProbability.LOW,
},
]);
assert.deepStrictEqual(cand0.content.parts, [
{ text: 'A' },
{ text: 'B' },
]);
const cand1 = sortedCandidates[1];
assert.strictEqual(cand1.index, 1);
assert.strictEqual(cand1.finishReason, FinishReason.STOP);
assert.strictEqual(cand1.safetyRatings, undefined);
assert.deepStrictEqual(cand1.content.parts, [
{ text: 'C1 ' },
{ text: 'D1' },
]);
assert.deepStrictEqual(aggregated.usageMetadata, { totalTokenCount: 20 });
});
it('should aggregate different part types', async () => {
const chunks = [
'data: {"candidates": [{"index": 0, "content": {"role": "model", "parts": [{"text": "Action: "}]}}]}\n\n',
'data: {"candidates": [{"index": 0, "content": {"role": "model", "parts": [{"functionCall": {"name": "tool_call", "args": {}}}]}}]}\n\n',
];
fetchSpy.resolves(createMockStream(chunks));
const result = await generateContentStream(apiKey, model, defaultRequest);
const aggregated = await result.response;
const cand = aggregated.candidates![0];
assert.deepStrictEqual(cand, {
index: 0,
content: {
role: 'model',
parts: [
{ text: 'Action: ' },
{ functionCall: { name: 'tool_call', args: {} } },
],
},
});
});
it('should handle stream with malformed JSON', async () => {
const chunks = [
'data: {"candidates": [{"index": 0, "content": {"role": "model", "parts": [{"text": "Hello "}]}}]}\n\n',
'data: {"candi dates": []}}}}\n\n', // Malformed
];
fetchSpy.resolves(createMockStream(chunks));
const result: GenerateContentStreamResult = await generateContentStream(
apiKey,
model,
defaultRequest
);
const streamResults: GenerateContentResponse[] = [];
try {
for await (const item of result.stream) {
streamResults.push(item);
}
assert.fail('Stream should have thrown an error');
} catch (e: any) {
assert.match(e.message, /Error parsing JSON response/);
}
try {
await result.response;
assert.fail('Response promise should have thrown an error');
} catch (e: any) {
assert.match(e.message, /Error parsing JSON response/);
}
});
it('should throw error if fetch rejects for stream', async () => {
fetchSpy.rejects(new Error('Stream failed to connect'));
await assert.rejects(
generateContentStream(apiKey, model, defaultRequest),
/Failed to fetch from .* Stream failed to connect/
);
});
it('should respect AbortSignal', async () => {
const controller = new AbortController(); // The test's controller
const clientOptions: ClientOptions = { signal: controller.signal };
const model = 'gemini-2.5-flash';
const defaultRequest: GenerateContentRequest = {
contents: [{ role: 'user', parts: [{ text: 'stream test' }] }],
};
const apiKey = 'test-api-key';
let capturedSignal: AbortSignal | undefined;
fetchSpy.callsFake((url, options) => {
return new Promise((resolve, reject) => {
const signal = options?.signal;
capturedSignal = signal; // Capture the signal passed to fetch
if (signal) {
if (signal.aborted) {
return reject(
new DOMException('The operation was aborted.', 'AbortError')
);
}
signal.addEventListener(
'abort',
() => {
reject(
new DOMException('The operation was aborted.', 'AbortError')
);
},
{ once: true }
);
}
// Keep promise pending to simulate a real network request
});
});
const promise = generateContentStream(
apiKey,
model,
defaultRequest,
clientOptions
);
// Short delay to allow fetchSpy to be called
await new Promise((resolve) => setTimeout(resolve, 0));
sinon.assert.calledOnce(fetchSpy);
assert.ok(capturedSignal, 'A signal should be passed to fetch');
assert.notStrictEqual(
capturedSignal,
controller.signal,
'The signal passed to fetch should be a new signal, not the original one'
);
assert.strictEqual(
capturedSignal!.aborted,
false,
'The passed signal should not be aborted initially'
);
// Abort the test's controller
controller.abort();
// Verify the promise from generateContentStream rejects due to the abort
await assert.rejects(promise, (err: Error) => {
assert.match(
err.message,
/Failed to fetch from .* The operation was aborted./
);
return true;
});
// Verify the signal captured within fetchSpy is now aborted
assert.strictEqual(
capturedSignal!.aborted,
true,
'The passed signal should be aborted after the original controller is aborted'
);
});
});
describe('veoPredict', () => {
const model = 'veo-1.5-flash-0804';
const request: VeoPredictRequest = {
instances: [{ prompt: 'A video of a sunset' }],
parameters: {},
};
it('should return VeoOperation', async () => {
const mockResponse: VeoOperation = {
name: 'operations/12345',
done: false,
};
mockFetchResponse(mockResponse);
const result = await veoPredict(apiKey, model, request);
assert.deepStrictEqual(result, mockResponse);
const expectedUrl = `https://generativelanguage.googleapis.com/v1beta/models/${model}:predictLongRunning`;
sinon.assert.calledOnceWithExactly(fetchSpy, expectedUrl, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'x-goog-api-key': apiKey,
'x-goog-api-client': getGenkitClientHeader(),
},
redirect: 'manual',
body: JSON.stringify(request),
});
});
it('should throw on API error', async () => {
const errorResponse = { error: { message: 'Veo failed to start' } };
mockFetchResponse(errorResponse, false, 500, 'Internal Server Error');
await assert.rejects(veoPredict(apiKey, model, request), (err: any) => {
assert.strictEqual(err.name, 'GenkitError');
assert.strictEqual(err.status, 'INTERNAL');
assert.match(
err.message,
/Error fetching from .* \[500 Internal Server Error\] Veo failed to start/
);
return true;
});
});
});
describe('veoCheckOperation', () => {
const operationName = 'operations/12345';
it('should return VeoOperation state', async () => {
const mockResponse: VeoOperation = {
name: operationName,
done: true,
response: {
generateVideoResponse: {
generatedSamples: [{ video: { uri: 'gs://bucket/video.mp4' } }],
},
},
};
mockFetchResponse(mockResponse);
const result = await veoCheckOperation(apiKey, operationName);
assert.deepStrictEqual(result, mockResponse);
const expectedUrl = `https://generativelanguage.googleapis.com/v1beta/${operationName}`;
sinon.assert.calledOnceWithExactly(fetchSpy, expectedUrl, {
method: 'GET',
headers: {
'Content-Type': 'application/json',
'x-goog-api-key': apiKey,
'x-goog-api-client': getGenkitClientHeader(),
},
redirect: 'manual',
});
});
it('should throw on API error', async () => {
const errorResponse = { error: { message: 'Operation not found' } };
mockFetchResponse(errorResponse, false, 404, 'Not Found');
await assert.rejects(
veoCheckOperation(apiKey, operationName),
(err: any) => {
assert.strictEqual(err.name, 'GenkitError');
assert.strictEqual(err.status, 'UNKNOWN');
assert.match(
err.message,
/Error fetching from .* \[404 Not Found\] Operation not found/
);
return true;
}
);
});
});
describe('TEST_ONLY.getAbortSignal', () => {
let clock: sinon.SinonFakeTimers;
beforeEach(() => {
clock = sinon.useFakeTimers();
});
afterEach(() => {
clock.restore();
});
it('should return undefined if no signal or timeout', () => {
const signal = getAbortSignal({});
assert.strictEqual(signal, undefined);
});
it('should return a signal that aborts after timeout', () => {
const clientOptions: ClientOptions = { timeout: 100 };
const signal = getAbortSignal(clientOptions);
assert.ok(signal);
assert.strictEqual(signal!.aborted, false);
clock.tick(100);
assert.strictEqual(signal!.aborted, true);
});
it('should return a signal linked to the provided signal', () => {
const controller = new AbortController();
const clientOptions: ClientOptions = { signal: controller.signal };
const signal = getAbortSignal(clientOptions);
assert.ok(signal);
assert.strictEqual(signal!.aborted, false);
controller.abort();
assert.strictEqual(signal!.aborted, true);
});
it('should return a signal that aborts on timeout or provided signal', () => {
const controller = new AbortController();
const clientOptions: ClientOptions = {
signal: controller.signal,
timeout: 100,
};
const signal = getAbortSignal(clientOptions);
assert.ok(signal);
clock.tick(50);
assert.strictEqual(signal!.aborted, false);
controller.abort();
assert.strictEqual(signal!.aborted, true);
const signal2 = getAbortSignal(clientOptions);
clock.tick(100);
assert.strictEqual(signal2!.aborted, true);
});
});
});