Files
wehub-resource-sync 0d3cb498a3
CI / Shell Format Check (push) Has been cancelled
CI / Check Ruby (3.4) (push) Has been cancelled
CI / CI Config (push) Has been cancelled
CI / Test on Node ${{ matrix.node }} and ${{ matrix.os }}${{ matrix.shard && format(' (shard {0}/3)', matrix.shard) || '' }} (push) Has been cancelled
CI / Build on Node ${{ matrix.node }} (push) Has been cancelled
CI / Style Check (push) Has been cancelled
CI / Generate Assets (push) Has been cancelled
CI / Check Python (3.14) (push) Has been cancelled
CI / Check Python (3.9) (push) Has been cancelled
CI / Build Docs (push) Has been cancelled
CI / Code Scan Action (push) Has been cancelled
CI / Site tests (push) Has been cancelled
CI / webui tests (push) Has been cancelled
CI / Run Integration Tests (push) Has been cancelled
CI / Run Smoke Tests (push) Has been cancelled
CI / Go Tests (push) Has been cancelled
CI / Share Test (push) Has been cancelled
CI / Redteam (Production API) (push) Has been cancelled
CI / Redteam (Staging API) (push) Has been cancelled
CI / GitHub Actions Lint (push) Has been cancelled
CI / Check Ruby (3.0) (push) Has been cancelled
release-please / release-please (push) Has been cancelled
release-please / build (push) Has been cancelled
release-please / publish-npm (push) Has been cancelled
release-please / publish-npm-backfill (push) Has been cancelled
release-please / docker (push) Has been cancelled
release-please / publish-code-scan-action (push) Has been cancelled
release-please / attest-code-scan-action (push) Has been cancelled
Deploy local.promptfoo.app / Deploy to Cloudflare Pages (push) Has been cancelled
Test and Publish Multi-arch Docker Image / test (push) Has been cancelled
Test and Publish Multi-arch Docker Image / build-docker-and-push-digests (map[digest-suffix:linux-amd64 platform:linux/amd64 runner:ubuntu-latest]) (push) Has been cancelled
Test and Publish Multi-arch Docker Image / build-docker-and-push-digests (map[digest-suffix:linux-arm64 platform:linux/arm64 runner:ubuntu-24.04-arm]) (push) Has been cancelled
Test and Publish Multi-arch Docker Image / merge-docker-digests (push) Has been cancelled
Test and Publish Multi-arch Docker Image / Attest Multi-arch Image (push) Has been cancelled
Validate Renovate Config / Validate Renovate Configuration (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 13:24:08 +08:00

1820 lines
56 KiB
TypeScript

import { WatsonXAI } from '@ibm-cloud/watsonx-ai';
import { BearerTokenAuthenticator, IamAuthenticator } from 'ibm-cloud-sdk-core';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { fetchWithCache, getCache, isCacheEnabled } from '../../src/cache';
import * as envarsModule from '../../src/envars';
import logger from '../../src/logger';
import {
clearModelSpecsCache,
generateConfigHash,
WatsonXChatProvider,
WatsonXProvider,
} from '../../src/providers/watsonx';
import { createEmptyTokenUsage } from '../../src/util/tokenUsageUtils';
vi.mock('../../src/logger', () => ({
default: {
debug: vi.fn(),
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
},
getLogLevel: vi.fn().mockReturnValue('info'),
}));
vi.mock('@ibm-cloud/watsonx-ai', async (importOriginal) => {
return {
...(await importOriginal()),
WatsonXAI: {
newInstance: vi.fn(),
},
};
});
vi.mock('ibm-cloud-sdk-core', async (importOriginal) => {
return {
...(await importOriginal()),
IamAuthenticator: vi.fn(),
BearerTokenAuthenticator: vi.fn(),
};
});
vi.mock('../../src/cache', async (importOriginal) => {
return {
...(await importOriginal()),
getCache: vi.fn(),
isCacheEnabled: vi.fn(),
fetchWithCache: vi.fn().mockImplementation(async function () {
return {
data: {
resources: [
{
model_id: 'meta-llama/llama-3-2-1b-instruct',
input_tier: 'class_c1',
output_tier: 'class_c1',
label: 'llama-3-2-1b-instruct',
provider: 'Meta',
source: 'Hugging Face',
model_limits: {
max_sequence_length: 131072,
max_output_tokens: 8192,
},
},
],
},
cached: false,
};
}),
};
});
vi.mock('../../src/envars', async (importOriginal) => {
return {
...(await importOriginal()),
getEnvString: vi.fn(),
getEnvInt: vi.fn(),
};
});
describe('WatsonXProvider', () => {
const modelName = 'test-model';
const config = {
apiKey: 'test-api-key',
projectId: 'test-project-id',
modelId: 'test-model-id',
maxNewTokens: 50,
};
const prompt = 'Test prompt';
beforeEach(() => {
vi.clearAllMocks();
clearModelSpecsCache();
});
afterEach(() => {
vi.clearAllMocks();
clearModelSpecsCache();
});
describe('constructor', () => {
it('should initialize with modelName and config', async () => {
const mockedWatsonXAIClient: Partial<any> = {
generateText: vi.fn(),
};
vi.mocked(WatsonXAI.newInstance).mockImplementation(function () {
return mockedWatsonXAIClient as any;
});
const provider = new WatsonXProvider(modelName, { config });
expect(provider.modelName).toBe(modelName);
expect(provider.options.config).toEqual(config);
// Get client to trigger authentication
await provider.getClient();
expect(logger.info).toHaveBeenCalledWith('Using IAM Authentication.');
});
it('should initialize with default id based on modelName', () => {
const mockedWatsonXAIClient: Partial<any> = {
generateText: vi.fn(),
};
vi.mocked(WatsonXAI.newInstance).mockImplementation(function () {
return mockedWatsonXAIClient as any;
});
const provider = new WatsonXProvider(modelName, { config });
expect(provider.id()).toBe(`watsonx:${modelName}`);
});
});
describe('id', () => {
it('should return the correct id string', () => {
const mockedWatsonXAIClient: Partial<any> = {
generateText: vi.fn(),
};
vi.mocked(WatsonXAI.newInstance).mockImplementation(function () {
return mockedWatsonXAIClient as any;
});
const provider = new WatsonXProvider(modelName, { config });
expect(provider.id()).toBe(`watsonx:${modelName}`);
});
});
describe('toString', () => {
it('should return the correct string representation', () => {
const mockedWatsonXAIClient: Partial<any> = {
generateText: vi.fn(),
};
vi.mocked(WatsonXAI.newInstance).mockImplementation(function () {
return mockedWatsonXAIClient as any;
});
const provider = new WatsonXProvider(modelName, { config });
expect(provider.toString()).toBe(`[Watsonx Provider ${modelName}]`);
});
});
describe('getClient', () => {
it('should initialize WatsonXAI client with correct parameters', async () => {
const mockedWatsonXAIClient: Partial<any> = {
generateText: vi.fn(),
};
vi.mocked(WatsonXAI.newInstance).mockImplementation(function () {
return mockedWatsonXAIClient as any;
});
const provider = new WatsonXProvider(modelName, { config });
const client = await provider.getClient();
expect(WatsonXAI.newInstance).toHaveBeenCalledWith({
version: '2023-05-29',
serviceUrl: 'https://us-south.ml.cloud.ibm.com',
authenticator: expect.any(IamAuthenticator),
});
expect(IamAuthenticator).toHaveBeenCalledWith({ apikey: 'test-api-key' });
expect(client).toBe(mockedWatsonXAIClient);
});
it('should throw an error if neither API key nor Bearer Token is set', async () => {
vi.spyOn(envarsModule, 'getEnvString').mockReturnValue(undefined as any);
const provider = new WatsonXProvider(modelName, {
config: { ...config, apiKey: undefined, apiBearerToken: undefined },
});
await expect(provider.getClient()).rejects.toThrow(
/Authentication credentials not provided\. Please set either `WATSONX_AI_APIKEY` for IAM Authentication or `WATSONX_AI_BEARER_TOKEN` for Bearer Token Authentication\./,
);
});
});
describe('constructor with Bearer Token', () => {
it('should use Bearer Token Authentication when apiKey is not provided', async () => {
const bearerTokenConfig = {
...config,
apiKey: undefined,
apiBearerToken: 'test-bearer-token',
};
const mockedWatsonXAIClient: Partial<any> = {
generateText: vi.fn(),
};
vi.mocked(WatsonXAI.newInstance).mockImplementation(function () {
return mockedWatsonXAIClient as any;
});
const provider = new WatsonXProvider(modelName, { config: bearerTokenConfig });
await provider.getClient();
expect(logger.info).toHaveBeenCalledWith('Using Bearer Token Authentication.');
expect(WatsonXAI.newInstance).toHaveBeenCalledWith({
version: '2023-05-29',
serviceUrl: 'https://us-south.ml.cloud.ibm.com',
authenticator: expect.any(BearerTokenAuthenticator),
});
});
it('should prefer API Key Authentication over Bearer Token Authentication when both are provided', async () => {
const dualAuthConfig = { ...config, apiBearerToken: 'test-bearer-token' };
const mockedWatsonXAIClient: Partial<any> = {
generateText: vi.fn(),
};
vi.mocked(WatsonXAI.newInstance).mockImplementation(function () {
return mockedWatsonXAIClient as any;
});
const provider = new WatsonXProvider(modelName, { config: dualAuthConfig });
await provider.getClient();
expect(logger.info).toHaveBeenCalledWith('Using IAM Authentication.');
expect(WatsonXAI.newInstance).toHaveBeenCalledWith({
version: '2023-05-29',
serviceUrl: 'https://us-south.ml.cloud.ibm.com',
authenticator: expect.any(IamAuthenticator),
});
});
it('should use IAM Authentication when WATSONX_AI_AUTH_TYPE is set to iam', async () => {
const dualAuthConfig = { ...config, apiBearerToken: 'test-bearer-token' };
const mockedWatsonXAIClient: Partial<any> = {
generateText: vi.fn(),
};
vi.mocked(WatsonXAI.newInstance).mockImplementation(function () {
return mockedWatsonXAIClient as any;
});
const provider = new WatsonXProvider(modelName, {
config: dualAuthConfig,
env: { WATSONX_AI_AUTH_TYPE: 'iam' },
});
await provider.getClient();
expect(logger.info).toHaveBeenCalledWith(
'Using IAM Authentication based on WATSONX_AI_AUTH_TYPE.',
);
expect(WatsonXAI.newInstance).toHaveBeenCalledWith({
version: '2023-05-29',
serviceUrl: 'https://us-south.ml.cloud.ibm.com',
authenticator: expect.any(IamAuthenticator),
});
});
it('should use Bearer Token Authentication when WATSONX_AI_AUTH_TYPE is set to bearertoken', async () => {
const dualAuthConfig = {
...config,
apiKey: 'test-api-key',
apiBearerToken: 'test-bearer-token',
};
const mockedWatsonXAIClient: Partial<any> = {
generateText: vi.fn(),
};
vi.mocked(WatsonXAI.newInstance).mockImplementation(function () {
return mockedWatsonXAIClient as any;
});
const provider = new WatsonXProvider(modelName, {
config: dualAuthConfig,
env: { WATSONX_AI_AUTH_TYPE: 'bearertoken' },
});
await provider.getClient();
expect(logger.info).toHaveBeenCalledWith(
'Using Bearer Token Authentication based on WATSONX_AI_AUTH_TYPE.',
);
expect(WatsonXAI.newInstance).toHaveBeenCalledWith({
version: '2023-05-29',
serviceUrl: 'https://us-south.ml.cloud.ibm.com',
authenticator: expect.any(BearerTokenAuthenticator),
});
});
it('should fallback to default behavior when WATSONX_AI_AUTH_TYPE is invalid', async () => {
const dualAuthConfig = { ...config, apiBearerToken: 'test-bearer-token' };
const mockedWatsonXAIClient: Partial<any> = {
generateText: vi.fn(),
};
vi.mocked(WatsonXAI.newInstance).mockImplementation(function () {
return mockedWatsonXAIClient as any;
});
const provider = new WatsonXProvider(modelName, {
config: dualAuthConfig,
env: { WATSONX_AI_AUTH_TYPE: 'invalid' },
});
await provider.getClient();
expect(logger.info).toHaveBeenCalledWith('Using IAM Authentication.');
expect(WatsonXAI.newInstance).toHaveBeenCalledWith({
version: '2023-05-29',
serviceUrl: 'https://us-south.ml.cloud.ibm.com',
authenticator: expect.any(IamAuthenticator),
});
});
});
describe('callApi', () => {
it('should call generateText with correct parameters and return the correct response', async () => {
const mockedWatsonXAIClient: Partial<any> = {
generateText: vi.fn().mockResolvedValue({
result: {
model_id: 'ibm/test-model',
model_version: '1.0.0',
created_at: '2023-10-10T00:00:00Z',
results: [
{
generated_text: 'Test response from WatsonX',
generated_token_count: 10,
input_token_count: 5,
stop_reason: 'max_tokens',
},
],
},
}),
};
vi.mocked(WatsonXAI.newInstance).mockImplementation(function () {
return mockedWatsonXAIClient as any;
});
const cache: Partial<any> = {
get: vi.fn().mockResolvedValue(null),
set: vi.fn(),
wrap: vi.fn(),
del: vi.fn(),
reset: vi.fn(),
store: {} as any,
};
vi.mocked(getCache).mockImplementation(function () {
return cache as any;
});
vi.mocked(isCacheEnabled).mockImplementation(function () {
return true;
});
const provider = new WatsonXProvider(modelName, { config });
const response = await provider.callApi(prompt);
expect(mockedWatsonXAIClient.generateText).toHaveBeenCalledWith({
input: prompt,
modelId: config.modelId,
projectId: config.projectId,
parameters: {
max_new_tokens: config.maxNewTokens,
},
});
expect(response).toEqual({
error: undefined,
output: 'Test response from WatsonX',
tokenUsage: {
// generated_token_count=10 (completion), input_token_count=5 (prompt),
// total = prompt + completion = 15.
total: 15,
prompt: 5,
completion: 10,
},
cost: undefined,
cached: undefined,
logProbs: undefined,
});
const cacheKey = vi.mocked(cache.set).mock.calls[0][0] as string;
expect(cacheKey).toMatch(
new RegExp(
`^watsonx:v2:${modelName}:${generateConfigHash(config)}:[a-f0-9]{64}:[a-f0-9]{64}$`,
),
);
expect(cacheKey).not.toContain(prompt);
expect(cacheKey).not.toContain(config.apiKey);
expect(cache.get).toHaveBeenCalledWith(cacheKey);
expect(cache.set).toHaveBeenCalledWith(cacheKey, JSON.stringify(response));
});
it.each([
{
name: 'missing token counts',
usage: {},
expected: { total: 0, prompt: 0, completion: 0 },
},
{
name: 'missing completion token count',
usage: { input_token_count: 5 },
expected: { total: 5, prompt: 5, completion: 0 },
},
{
name: 'missing prompt token count',
usage: { generated_token_count: 7 },
expected: { total: 7, prompt: 0, completion: 7 },
},
{
name: 'zero completion tokens',
usage: { input_token_count: 5, generated_token_count: 0 },
expected: { total: 5, prompt: 5, completion: 0 },
},
{
name: 'zero prompt tokens',
usage: { input_token_count: 0, generated_token_count: 7 },
expected: { total: 7, prompt: 0, completion: 7 },
},
])('should handle $name with caching disabled', async ({ usage, expected }) => {
const mockedWatsonXAIClient: Partial<any> = {
generateText: vi.fn().mockResolvedValue({
result: {
model_id: 'ibm/test-model',
model_version: '1.0.0',
created_at: '2023-10-10T00:00:00Z',
results: [{ generated_text: 'Boundary response', ...usage }],
},
}),
};
vi.mocked(WatsonXAI.newInstance).mockImplementation(function () {
return mockedWatsonXAIClient as any;
});
const cache = {
get: vi.fn(),
set: vi.fn(),
};
vi.mocked(getCache).mockImplementation(function () {
return cache as any;
});
vi.mocked(isCacheEnabled).mockImplementation(function () {
return false;
});
const response = await new WatsonXProvider(modelName, { config }).callApi(prompt);
expect(response.tokenUsage).toEqual(expected);
expect(response.cost).toBeUndefined();
expect(cache.get).not.toHaveBeenCalled();
expect(cache.set).not.toHaveBeenCalled();
});
it('should include configured auth mode in cache keys without exposing secrets', async () => {
const mockedWatsonXAIClient: Partial<any> = {
generateText: vi.fn().mockResolvedValue({
result: {
model_id: 'ibm/test-model',
model_version: '1.0.0',
created_at: '2023-10-10T00:00:00Z',
results: [
{
generated_text: 'Credential-separated response',
generated_token_count: 1,
input_token_count: 1,
stop_reason: 'max_tokens',
},
],
},
}),
};
vi.mocked(WatsonXAI.newInstance).mockImplementation(function () {
return mockedWatsonXAIClient as any;
});
const cache: Partial<any> = {
get: vi.fn().mockResolvedValue(null),
set: vi.fn(),
};
vi.mocked(getCache).mockImplementation(function () {
return cache as any;
});
vi.mocked(isCacheEnabled).mockImplementation(function () {
return true;
});
const apiKey = 'watsonx-config-secret-api-key';
const bearerToken = 'watsonx-config-secret-bearer-token';
await new WatsonXProvider(modelName, {
config: { ...config, apiKey },
}).callApi(prompt);
await new WatsonXProvider(modelName, {
config: { ...config, apiKey: undefined, apiBearerToken: bearerToken },
env: { WATSONX_AI_AUTH_TYPE: 'bearertoken' },
}).callApi(prompt);
const firstCacheKey = vi.mocked(cache.set).mock.calls[0][0] as string;
const secondCacheKey = vi.mocked(cache.set).mock.calls[1][0] as string;
expect(firstCacheKey).not.toEqual(secondCacheKey);
expect(firstCacheKey).not.toContain(apiKey);
expect(secondCacheKey).not.toContain(bearerToken);
});
it('should include env-sourced auth mode in cache keys without exposing secrets', async () => {
const mockedWatsonXAIClient: Partial<any> = {
generateText: vi.fn().mockResolvedValue({
result: {
model_id: 'ibm/test-model',
model_version: '1.0.0',
created_at: '2023-10-10T00:00:00Z',
results: [
{
generated_text: 'Env credential-separated response',
generated_token_count: 1,
input_token_count: 1,
stop_reason: 'max_tokens',
},
],
},
}),
};
vi.mocked(WatsonXAI.newInstance).mockImplementation(function () {
return mockedWatsonXAIClient as any;
});
const cache: Partial<any> = {
get: vi.fn().mockResolvedValue(null),
set: vi.fn(),
};
vi.mocked(getCache).mockImplementation(function () {
return cache as any;
});
vi.mocked(isCacheEnabled).mockImplementation(function () {
return true;
});
const apiKey = 'watsonx-env-secret-api-key';
const bearerToken = 'watsonx-env-secret-bearer-token';
const envConfig = { ...config, apiKey: undefined };
await new WatsonXProvider(modelName, {
config: envConfig,
env: { WATSONX_AI_APIKEY: apiKey },
}).callApi(prompt);
await new WatsonXProvider(modelName, {
config: envConfig,
env: {
WATSONX_AI_BEARER_TOKEN: bearerToken,
WATSONX_AI_AUTH_TYPE: 'bearertoken',
},
}).callApi(prompt);
const firstCacheKey = vi.mocked(cache.set).mock.calls[0][0] as string;
const secondCacheKey = vi.mocked(cache.set).mock.calls[1][0] as string;
expect(firstCacheKey).not.toEqual(secondCacheKey);
expect(firstCacheKey).not.toContain(apiKey);
expect(secondCacheKey).not.toContain(bearerToken);
});
it('should separate cache keys for different same-mode credentials without exposing secrets', async () => {
const mockedWatsonXAIClient: Partial<any> = {
generateText: vi.fn().mockResolvedValue({
result: {
model_id: 'ibm/test-model',
model_version: '1.0.0',
created_at: '2023-10-10T00:00:00Z',
results: [
{
generated_text: 'Same-mode credential-separated response',
generated_token_count: 1,
input_token_count: 1,
stop_reason: 'max_tokens',
},
],
},
}),
};
vi.mocked(WatsonXAI.newInstance).mockImplementation(function () {
return mockedWatsonXAIClient as any;
});
const cache: Partial<any> = {
get: vi.fn().mockResolvedValue(null),
set: vi.fn(),
};
vi.mocked(getCache).mockImplementation(function () {
return cache as any;
});
vi.mocked(isCacheEnabled).mockImplementation(function () {
return true;
});
const apiKeyA = 'watsonx-config-secret-api-key-a';
const apiKeyB = 'watsonx-config-secret-api-key-b';
const bearerTokenA = 'watsonx-config-secret-bearer-token-a';
const bearerTokenB = 'watsonx-config-secret-bearer-token-b';
await new WatsonXProvider(modelName, {
config: { ...config, apiKey: apiKeyA },
}).callApi(prompt);
await new WatsonXProvider(modelName, {
config: { ...config, apiKey: apiKeyB },
}).callApi(prompt);
await new WatsonXProvider(modelName, {
config: { ...config, apiKey: undefined, apiBearerToken: bearerTokenA },
}).callApi(prompt);
await new WatsonXProvider(modelName, {
config: { ...config, apiKey: undefined, apiBearerToken: bearerTokenB },
}).callApi(prompt);
const iamKeyA = vi.mocked(cache.set).mock.calls[0][0] as string;
const iamKeyB = vi.mocked(cache.set).mock.calls[1][0] as string;
const bearerKeyA = vi.mocked(cache.set).mock.calls[2][0] as string;
const bearerKeyB = vi.mocked(cache.set).mock.calls[3][0] as string;
expect(iamKeyA).not.toEqual(iamKeyB);
expect(bearerKeyA).not.toEqual(bearerKeyB);
expect(iamKeyA).not.toContain(apiKeyA);
expect(iamKeyB).not.toContain(apiKeyB);
expect(bearerKeyA).not.toContain(bearerTokenA);
expect(bearerKeyB).not.toContain(bearerTokenB);
});
it('should keep cache keys stable across provider instances with the same credentials', async () => {
const mockedWatsonXAIClient: Partial<any> = {
generateText: vi.fn().mockResolvedValue({
result: {
model_id: 'ibm/test-model',
model_version: '1.0.0',
created_at: '2023-10-10T00:00:00Z',
results: [
{
generated_text: 'Stable credential response',
generated_token_count: 1,
input_token_count: 1,
stop_reason: 'max_tokens',
},
],
},
}),
};
vi.mocked(WatsonXAI.newInstance).mockImplementation(function () {
return mockedWatsonXAIClient as any;
});
const cache: Partial<any> = {
get: vi.fn().mockResolvedValue(null),
set: vi.fn(),
};
vi.mocked(getCache).mockImplementation(function () {
return cache as any;
});
vi.mocked(isCacheEnabled).mockImplementation(function () {
return true;
});
const sharedApiKey = 'watsonx-shared-stable-api-key';
await new WatsonXProvider(modelName, {
config: { ...config, apiKey: sharedApiKey },
}).callApi(prompt);
await new WatsonXProvider(modelName, {
config: { ...config, apiKey: sharedApiKey },
}).callApi(prompt);
const firstCacheKey = vi.mocked(cache.set).mock.calls[0][0] as string;
const secondCacheKey = vi.mocked(cache.set).mock.calls[1][0] as string;
expect(firstCacheKey).toEqual(secondCacheKey);
expect(firstCacheKey).not.toContain(sharedApiKey);
});
it('should keep the cache auth hash tied to the memoized client credentials', async () => {
const mockedWatsonXAIClient: Partial<any> = {
generateText: vi.fn().mockResolvedValue({
result: {
model_id: 'ibm/test-model',
model_version: '1.0.0',
created_at: '2023-10-10T00:00:00Z',
results: [
{
generated_text: 'Memoized credential response',
generated_token_count: 1,
input_token_count: 1,
stop_reason: 'max_tokens',
},
],
},
}),
};
vi.mocked(WatsonXAI.newInstance).mockImplementation(function () {
return mockedWatsonXAIClient as any;
});
const cache: Partial<any> = {
get: vi.fn().mockResolvedValue(null),
set: vi.fn(),
};
vi.mocked(getCache).mockImplementation(function () {
return cache as any;
});
vi.mocked(isCacheEnabled).mockImplementation(function () {
return true;
});
const originalApiKey = 'watsonx-original-env-api-key';
const rotatedApiKey = 'watsonx-rotated-env-api-key';
const provider = new WatsonXProvider(modelName, {
config: { ...config, apiKey: undefined },
env: { WATSONX_AI_APIKEY: originalApiKey },
});
await provider.callApi(prompt);
provider.env = { WATSONX_AI_APIKEY: rotatedApiKey };
await provider.callApi(prompt);
const firstCacheKey = vi.mocked(cache.set).mock.calls[0][0] as string;
const secondCacheKey = vi.mocked(cache.set).mock.calls[1][0] as string;
expect(firstCacheKey).toEqual(secondCacheKey);
expect(firstCacheKey).not.toContain(originalApiKey);
expect(secondCacheKey).not.toContain(rotatedApiKey);
expect(WatsonXAI.newInstance).toHaveBeenCalledTimes(1);
expect(IamAuthenticator).toHaveBeenCalledWith({ apikey: originalApiKey });
expect(IamAuthenticator).not.toHaveBeenCalledWith({ apikey: rotatedApiKey });
});
it('should preserve an explicit maxNewTokens value of 0', async () => {
const zeroConfig = {
...config,
maxNewTokens: 0,
};
const mockedWatsonXAIClient: Partial<any> = {
generateText: vi.fn().mockResolvedValue({
result: {
model_id: 'ibm/test-model',
model_version: '1.0.0',
created_at: '2023-10-10T00:00:00Z',
results: [
{
generated_text: 'Test response from WatsonX',
generated_token_count: 0,
input_token_count: 5,
stop_reason: 'max_tokens',
},
],
},
}),
};
vi.mocked(WatsonXAI.newInstance).mockImplementation(function () {
return mockedWatsonXAIClient as any;
});
vi.mocked(getCache).mockImplementation(() => null as any);
vi.mocked(isCacheEnabled).mockImplementation(() => false);
const provider = new WatsonXProvider(modelName, { config: zeroConfig });
await provider.callApi(prompt);
expect(mockedWatsonXAIClient.generateText).toHaveBeenCalledWith(
expect.objectContaining({
parameters: expect.objectContaining({
max_new_tokens: 0,
}),
}),
);
});
it('should return cached response if available', async () => {
// What's stored in the cache doesn't have cached: true
const storedCachedData = {
error: undefined,
output: 'Cached response',
tokenUsage: {
total: 8,
prompt: 3,
completion: 5,
},
cost: undefined,
cached: undefined,
logProbs: undefined,
};
// But the response should have cached: true added
const expectedResponse = {
...storedCachedData,
cached: true,
};
const cache: Partial<any> = {
get: vi.fn().mockResolvedValue(JSON.stringify(storedCachedData)),
set: vi.fn(),
wrap: vi.fn(),
del: vi.fn(),
reset: vi.fn(),
store: {} as any,
};
vi.mocked(getCache).mockImplementation(function () {
return cache as any;
});
vi.mocked(isCacheEnabled).mockImplementation(function () {
return true;
});
// Must mock WatsonXAI.newInstance to ensure test isolation
const mockedWatsonXAIClient: Partial<any> = {
generateText: vi.fn(),
};
vi.mocked(WatsonXAI.newInstance).mockImplementation(function () {
return mockedWatsonXAIClient as any;
});
const provider = new WatsonXProvider(modelName, { config });
const generateTextSpy = vi.spyOn(await provider.getClient(), 'generateText');
const response = await provider.callApi(prompt);
const cacheKey = vi.mocked(cache.get).mock.calls[0][0] as string;
const debugLogs = JSON.stringify(vi.mocked(logger.debug).mock.calls);
expect(cacheKey).toMatch(
new RegExp(
`^watsonx:v2:${modelName}:${generateConfigHash(config)}:[a-f0-9]{64}:[a-f0-9]{64}$`,
),
);
expect(cacheKey).not.toContain(prompt);
expect(cacheKey).not.toContain(config.apiKey);
expect(debugLogs).not.toContain(prompt);
expect(debugLogs).not.toContain(storedCachedData.output);
expect(cache.get).toHaveBeenCalledWith(cacheKey);
expect(response).toEqual(expectedResponse);
expect(generateTextSpy).not.toHaveBeenCalled();
});
it('should handle API errors gracefully', async () => {
const mockedWatsonXAIClient: Partial<any> = {
generateText: vi.fn().mockRejectedValue(new Error('API error')),
};
vi.mocked(WatsonXAI.newInstance).mockImplementation(function () {
return mockedWatsonXAIClient as any;
});
const cache: Partial<any> = {
get: vi.fn().mockResolvedValue(null),
set: vi.fn(),
wrap: vi.fn(),
del: vi.fn(),
reset: vi.fn(),
store: {} as any,
};
vi.mocked(getCache).mockImplementation(function () {
return cache as any;
});
vi.mocked(isCacheEnabled).mockImplementation(function () {
return true;
});
const provider = new WatsonXProvider(modelName, { config });
const response = await provider.callApi(prompt);
expect(response).toEqual({
error: 'API call error: Error: API error',
output: '',
tokenUsage: createEmptyTokenUsage(),
});
expect(logger.error).toHaveBeenCalledWith('Watsonx: API call error: Error: API error');
});
it('should surface a clean error when the API returns no results', async () => {
const mockedWatsonXAIClient: Partial<any> = {
generateText: vi.fn().mockResolvedValue({
result: {
model_id: 'ibm/test-model',
model_version: '1.0.0',
created_at: '2023-10-10T00:00:00Z',
results: [],
},
}),
};
vi.mocked(WatsonXAI.newInstance).mockImplementation(function () {
return mockedWatsonXAIClient as any;
});
const cache = {
get: vi.fn().mockResolvedValue(null),
set: vi.fn(),
};
vi.mocked(getCache).mockImplementation(function () {
return cache as any;
});
vi.mocked(isCacheEnabled).mockImplementation(function () {
return true;
});
const response = await new WatsonXProvider(modelName, { config }).callApi(prompt);
expect(response.error).toContain('No results returned from text generation API.');
expect(response.cost).toBeUndefined();
expect(cache.set).not.toHaveBeenCalled();
});
});
describe('calculateWatsonXCost', () => {
const MODEL_ID = 'meta-llama/llama-3-3-70b-instruct';
const configWithModelId = {
...config,
modelId: MODEL_ID,
};
beforeEach(() => {
vi.clearAllMocks();
clearModelSpecsCache();
vi.mocked(fetchWithCache).mockImplementation(async function () {
return {
data: {
resources: [
{
model_id: MODEL_ID,
input_tier: 'class_c1',
output_tier: 'class_c1',
label: 'llama-3-3-70b-instruct',
provider: 'Meta',
source: 'Hugging Face',
model_limits: {
max_sequence_length: 131072,
max_output_tokens: 8192,
},
},
],
},
cached: false,
status: 200,
statusText: 'OK',
headers: {},
};
});
});
it('should calculate cost correctly when token counts are provided', async () => {
const mockedWatsonXAIClient: Partial<any> = {
generateText: vi.fn().mockResolvedValue({
result: {
model_id: MODEL_ID,
model_version: '3.2.0',
created_at: '2024-03-25T00:00:00Z',
results: [
{
generated_text: 'Test response',
generated_token_count: 100,
input_token_count: 50,
stop_reason: 'max_tokens',
},
],
},
}),
};
vi.mocked(WatsonXAI.newInstance).mockImplementation(function () {
return mockedWatsonXAIClient as any;
});
const cache: Partial<any> = {
get: vi.fn().mockResolvedValue(null),
set: vi.fn(),
};
vi.mocked(getCache).mockImplementation(function () {
return cache as any;
});
vi.mocked(isCacheEnabled).mockImplementation(function () {
return true;
});
const provider = new WatsonXProvider(MODEL_ID, { config: configWithModelId });
const response = await provider.callApi(prompt);
expect(response.cost).toBeDefined();
expect(typeof response.cost).toBe('number');
// For class_c1 tier ($0.106 per 1M tokens)
// Input: input_token_count = 50 tokens * $0.106/1M = 0.0000053
// Output: generated_token_count = 100 tokens * $0.106/1M = 0.0000106
// Total expected: 0.0000159
expect(response.cost).toBeCloseTo(0.0000159, 10);
});
it.each([
{
name: 'zero completion tokens',
inputTokens: 50,
generatedTokens: 0,
expectedCost: 0.0000053,
},
{
name: 'zero input tokens',
inputTokens: 0,
generatedTokens: 100,
expectedCost: 0.0000106,
},
{
name: 'missing token counts',
inputTokens: undefined,
generatedTokens: undefined,
expectedCost: undefined,
},
{
name: 'missing completion token count',
inputTokens: 50,
generatedTokens: undefined,
expectedCost: undefined,
},
{
name: 'missing input token count',
inputTokens: undefined,
generatedTokens: 100,
expectedCost: undefined,
},
])('should calculate cost with $name', async ({
inputTokens,
generatedTokens,
expectedCost,
}) => {
const mockedWatsonXAIClient: Partial<any> = {
generateText: vi.fn().mockResolvedValue({
result: {
model_id: MODEL_ID,
model_version: '3.2.0',
created_at: '2024-03-25T00:00:00Z',
results: [
{
generated_text: 'Boundary response',
generated_token_count: generatedTokens,
input_token_count: inputTokens,
},
],
},
}),
};
vi.mocked(WatsonXAI.newInstance).mockImplementation(function () {
return mockedWatsonXAIClient as any;
});
const cache = {
get: vi.fn().mockResolvedValue(null),
set: vi.fn(),
};
vi.mocked(getCache).mockImplementation(function () {
return cache as any;
});
vi.mocked(isCacheEnabled).mockImplementation(function () {
return true;
});
const response = await new WatsonXProvider(MODEL_ID, {
config: configWithModelId,
}).callApi(prompt);
if (expectedCost === undefined) {
expect(response.cost).toBeUndefined();
expect(fetchWithCache).not.toHaveBeenCalled();
} else {
expect(response.cost).toBeCloseTo(expectedCost, 10);
}
});
it('should calculate cost correctly for class_9 tier', async () => {
const modelId = 'meta-llama/llama-3-2-11b-vision-instruct';
const configWithClass9ModelId = { ...config, modelId };
clearModelSpecsCache();
vi.mocked(fetchWithCache).mockImplementation(async function () {
return {
data: {
resources: [
{
model_id: modelId,
label: 'llama-3-2-11b-vision-instruct',
provider: 'Meta',
source: 'Hugging Face',
functions: [{ id: 'image_chat' }, { id: 'text_chat' }, { id: 'text_generation' }],
input_tier: 'class_9',
output_tier: 'class_9',
number_params: '11b',
model_limits: {
max_sequence_length: 131072,
max_output_tokens: 8192,
},
},
],
},
cached: false,
status: 200,
statusText: 'OK',
headers: {},
};
});
const mockedWatsonXAIClient: Partial<any> = {
generateText: vi.fn().mockResolvedValue({
result: {
model_id: modelId,
model_version: '3.2.0',
created_at: '2024-03-25T00:00:00Z',
results: [
{
generated_text: 'Test response',
generated_token_count: 100,
input_token_count: 50,
stop_reason: 'max_tokens',
},
],
},
}),
};
vi.mocked(WatsonXAI.newInstance).mockImplementation(function () {
return mockedWatsonXAIClient as any;
});
const cache: Partial<any> = {
get: vi.fn().mockResolvedValue(null),
set: vi.fn(),
};
vi.mocked(getCache).mockImplementation(function () {
return cache as any;
});
vi.mocked(isCacheEnabled).mockImplementation(function () {
return true;
});
const provider = new WatsonXProvider(modelId, { config: configWithClass9ModelId });
const response = await provider.callApi(prompt);
expect(response.cost).toBeDefined();
expect(typeof response.cost).toBe('number');
// For class_9 tier ($0.371 per 1M tokens):
// input_token_count = 50 (prompt), generated_token_count = 100 (completion)
// Input: 50 tokens * $0.371/1M = 0.00001855
// Output: 100 tokens * $0.371/1M = 0.0000371
// Total expected: 0.00005565
expect(response.cost).toBeCloseTo(0.00005565, 10);
});
it('should calculate cost correctly for Granite 4 models with mixed pricing tiers', async () => {
const modelId = 'ibm/granite-4-h-small';
const configWithGraniteModelId = { ...config, modelId };
clearModelSpecsCache();
vi.mocked(fetchWithCache).mockImplementation(async function () {
return {
data: {
resources: [
{
model_id: modelId,
label: 'granite-4-h-small',
provider: 'IBM',
source: 'IBM',
functions: [{ id: 'text_chat' }, { id: 'text_generation' }],
input_tier: 'class_18',
output_tier: 'class_5',
number_params: '30b',
model_limits: {
max_sequence_length: 131072,
max_output_tokens: 16384,
},
},
],
},
cached: false,
status: 200,
statusText: 'OK',
headers: {},
};
});
const mockedWatsonXAIClient: Partial<any> = {
generateText: vi.fn().mockResolvedValue({
result: {
model_id: modelId,
model_version: '4.0.0',
created_at: '2024-10-01T00:00:00Z',
results: [
{
generated_text: 'Test response from Granite 4',
generated_token_count: 100,
input_token_count: 50,
stop_reason: 'max_tokens',
},
],
},
}),
};
vi.mocked(WatsonXAI.newInstance).mockImplementation(function () {
return mockedWatsonXAIClient as any;
});
const cache: Partial<any> = {
get: vi.fn().mockResolvedValue(null),
set: vi.fn(),
};
vi.mocked(getCache).mockImplementation(function () {
return cache as any;
});
vi.mocked(isCacheEnabled).mockImplementation(function () {
return true;
});
const provider = new WatsonXProvider(modelId, { config: configWithGraniteModelId });
const response = await provider.callApi(prompt);
expect(response.cost).toBeDefined();
expect(typeof response.cost).toBe('number');
// Input: input_token_count = 50 tokens * $0.0636/1M = 0.00000318
// Output: generated_token_count = 100 tokens * $0.265/1M = 0.0000265
// Total expected: 0.00002968
expect(response.cost).toBeCloseTo(0.00002968, 10);
});
it('should calculate cost correctly for special Mistral pricing tiers', async () => {
const modelId = 'mistralai/mistral-medium-2505';
const configWithMistralModelId = { ...config, modelId };
clearModelSpecsCache();
vi.mocked(fetchWithCache).mockImplementation(async function () {
return {
data: {
resources: [
{
model_id: modelId,
label: 'mistral-medium-2505',
provider: 'Mistral AI',
source: 'Mistral AI',
functions: [{ id: 'text_chat' }, { id: 'text_generation' }],
input_tier: 'Mistral Large Input',
output_tier: 'Mistral Large',
number_params: 'unknown',
model_limits: {
max_sequence_length: 131072,
max_output_tokens: 16384,
},
},
],
},
cached: false,
status: 200,
statusText: 'OK',
headers: {},
};
});
const mockedWatsonXAIClient: Partial<any> = {
generateText: vi.fn().mockResolvedValue({
result: {
model_id: modelId,
model_version: '2505',
created_at: '2025-05-01T00:00:00Z',
results: [
{
generated_text: 'Test response from Mistral Medium',
generated_token_count: 100,
input_token_count: 50,
stop_reason: 'max_tokens',
},
],
},
}),
};
vi.mocked(WatsonXAI.newInstance).mockImplementation(function () {
return mockedWatsonXAIClient as any;
});
const cache: Partial<any> = {
get: vi.fn().mockResolvedValue(null),
set: vi.fn(),
};
vi.mocked(getCache).mockImplementation(function () {
return cache as any;
});
vi.mocked(isCacheEnabled).mockImplementation(function () {
return true;
});
const provider = new WatsonXProvider(modelId, { config: configWithMistralModelId });
const response = await provider.callApi(prompt);
expect(response.cost).toBeDefined();
expect(typeof response.cost).toBe('number');
// input_token_count = 50 (prompt), generated_token_count = 100 (completion)
// Input: 50 tokens * $3.37/1M = 0.0001685
// Output: 100 tokens * $10.07/1M = 0.001007
// Total expected: 0.0011755
expect(response.cost).toBeCloseTo(0.0011755, 10);
});
});
describe('Enhanced Text Generation Parameters', () => {
it('should pass temperature parameter to API', async () => {
const configWithTemperature = {
...config,
temperature: 0.7,
};
const mockedWatsonXAIClient: Partial<any> = {
generateText: vi.fn().mockResolvedValue({
result: {
model_id: 'test-model',
model_version: '1.0.0',
created_at: '2023-10-10T00:00:00Z',
results: [
{
generated_text: 'Test response',
generated_token_count: 10,
input_token_count: 5,
stop_reason: 'max_tokens',
},
],
},
}),
};
vi.mocked(WatsonXAI.newInstance).mockImplementation(function () {
return mockedWatsonXAIClient as any;
});
const cache: Partial<any> = {
get: vi.fn().mockResolvedValue(null),
set: vi.fn(),
};
vi.mocked(getCache).mockImplementation(function () {
return cache as any;
});
vi.mocked(isCacheEnabled).mockImplementation(function () {
return true;
});
const provider = new WatsonXProvider(modelName, { config: configWithTemperature });
await provider.callApi(prompt);
expect(mockedWatsonXAIClient.generateText).toHaveBeenCalledWith(
expect.objectContaining({
parameters: expect.objectContaining({
temperature: 0.7,
}),
}),
);
});
it('should pass stop_sequences parameter to API', async () => {
const configWithStopSequences = {
...config,
stopSequences: ['END', 'STOP'],
};
const mockedWatsonXAIClient: Partial<any> = {
generateText: vi.fn().mockResolvedValue({
result: {
model_id: 'test-model',
model_version: '1.0.0',
created_at: '2023-10-10T00:00:00Z',
results: [
{
generated_text: 'Test response',
generated_token_count: 10,
input_token_count: 5,
stop_reason: 'max_tokens',
},
],
},
}),
};
vi.mocked(WatsonXAI.newInstance).mockImplementation(function () {
return mockedWatsonXAIClient as any;
});
const cache: Partial<any> = {
get: vi.fn().mockResolvedValue(null),
set: vi.fn(),
};
vi.mocked(getCache).mockImplementation(function () {
return cache as any;
});
vi.mocked(isCacheEnabled).mockImplementation(function () {
return true;
});
const provider = new WatsonXProvider(modelName, { config: configWithStopSequences });
await provider.callApi(prompt);
expect(mockedWatsonXAIClient.generateText).toHaveBeenCalledWith(
expect.objectContaining({
parameters: expect.objectContaining({
stop_sequences: ['END', 'STOP'],
}),
}),
);
});
it('should pass all text generation parameters to API', async () => {
const fullConfig = {
...config,
temperature: 0.8,
topP: 0.9,
topK: 50,
decodingMethod: 'sample' as const,
repetitionPenalty: 1.1,
minNewTokens: 10,
randomSeed: 42,
};
const mockedWatsonXAIClient: Partial<any> = {
generateText: vi.fn().mockResolvedValue({
result: {
model_id: 'test-model',
model_version: '1.0.0',
created_at: '2023-10-10T00:00:00Z',
results: [
{
generated_text: 'Test response',
generated_token_count: 10,
input_token_count: 5,
stop_reason: 'max_tokens',
},
],
},
}),
};
vi.mocked(WatsonXAI.newInstance).mockImplementation(function () {
return mockedWatsonXAIClient as any;
});
const cache: Partial<any> = {
get: vi.fn().mockResolvedValue(null),
set: vi.fn(),
};
vi.mocked(getCache).mockImplementation(function () {
return cache as any;
});
vi.mocked(isCacheEnabled).mockImplementation(function () {
return true;
});
const provider = new WatsonXProvider(modelName, { config: fullConfig });
await provider.callApi(prompt);
expect(mockedWatsonXAIClient.generateText).toHaveBeenCalledWith(
expect.objectContaining({
parameters: expect.objectContaining({
temperature: 0.8,
top_p: 0.9,
top_k: 50,
decoding_method: 'sample',
repetition_penalty: 1.1,
min_new_tokens: 10,
random_seed: 42,
}),
}),
);
});
it('should merge prompt-level config with provider config', async () => {
const providerConfig = {
...config,
temperature: 0.5,
};
const mockedWatsonXAIClient: Partial<any> = {
generateText: vi.fn().mockResolvedValue({
result: {
model_id: 'test-model',
model_version: '1.0.0',
created_at: '2023-10-10T00:00:00Z',
results: [
{
generated_text: 'Test response',
generated_token_count: 10,
input_token_count: 5,
stop_reason: 'max_tokens',
},
],
},
}),
};
vi.mocked(WatsonXAI.newInstance).mockImplementation(function () {
return mockedWatsonXAIClient as any;
});
const cache: Partial<any> = {
get: vi.fn().mockResolvedValue(null),
set: vi.fn(),
};
vi.mocked(getCache).mockImplementation(function () {
return cache as any;
});
vi.mocked(isCacheEnabled).mockImplementation(function () {
return true;
});
const provider = new WatsonXProvider(modelName, { config: providerConfig });
// Call with prompt-level config override
await provider.callApi(prompt, {
prompt: {
raw: prompt,
label: 'test',
config: { temperature: 0.9 },
},
vars: {},
});
// Prompt-level config should override provider config
expect(mockedWatsonXAIClient.generateText).toHaveBeenCalledWith(
expect.objectContaining({
parameters: expect.objectContaining({
temperature: 0.9,
}),
}),
);
});
});
});
describe('WatsonXChatProvider', () => {
const modelName = 'test-model';
const config = {
apiKey: 'test-api-key',
projectId: 'test-project-id',
modelId: 'test-model-id',
maxNewTokens: 50,
};
const prompt = 'Test prompt';
beforeEach(() => {
vi.clearAllMocks();
clearModelSpecsCache();
});
afterEach(() => {
vi.clearAllMocks();
clearModelSpecsCache();
});
it('should parse JSON chat messages and call textChat', async () => {
const chatPrompt = JSON.stringify([
{ role: 'system', content: 'You are helpful' },
{ role: 'user', content: 'Hello' },
]);
const mockedWatsonXAIClient: Partial<any> = {
generateText: vi.fn(),
textChat: vi.fn().mockResolvedValue({
result: {
choices: [
{
message: {
role: 'assistant',
content: 'Hello! How can I help you?',
},
finish_reason: 'stop',
},
],
usage: {
prompt_tokens: 10,
completion_tokens: 8,
total_tokens: 18,
},
},
}),
};
vi.mocked(WatsonXAI.newInstance).mockImplementation(function () {
return mockedWatsonXAIClient as any;
});
const cache: Partial<any> = {
get: vi.fn().mockResolvedValue(null),
set: vi.fn(),
};
vi.mocked(getCache).mockImplementation(function () {
return cache as any;
});
vi.mocked(isCacheEnabled).mockImplementation(function () {
return true;
});
const provider = new WatsonXChatProvider(modelName, { config });
const response = await provider.callApi(chatPrompt);
expect(mockedWatsonXAIClient.textChat).toHaveBeenCalledWith(
expect.objectContaining({
messages: [
{ role: 'system', content: 'You are helpful' },
{ role: 'user', content: 'Hello' },
],
}),
);
expect(response.output).toBe('Hello! How can I help you?');
expect(response.tokenUsage).toEqual({
prompt: 10,
completion: 8,
total: 18,
});
});
it('should fall back to user message for plain text', async () => {
const mockedWatsonXAIClient: Partial<any> = {
generateText: vi.fn(),
textChat: vi.fn().mockResolvedValue({
result: {
choices: [
{
message: {
role: 'assistant',
content: 'Response to plain text',
},
finish_reason: 'stop',
},
],
usage: {
prompt_tokens: 5,
completion_tokens: 4,
total_tokens: 9,
},
},
}),
};
vi.mocked(WatsonXAI.newInstance).mockImplementation(function () {
return mockedWatsonXAIClient as any;
});
const cache: Partial<any> = {
get: vi.fn().mockResolvedValue(null),
set: vi.fn(),
};
vi.mocked(getCache).mockImplementation(function () {
return cache as any;
});
vi.mocked(isCacheEnabled).mockImplementation(function () {
return true;
});
const chatPrompt = 'PFQA_WATSONX_CHAT_PROMPT_SENTINEL';
const provider = new WatsonXChatProvider(modelName, { config });
await provider.callApi(chatPrompt);
expect(mockedWatsonXAIClient.textChat).toHaveBeenCalledWith(
expect.objectContaining({
messages: [{ role: 'user', content: chatPrompt }],
}),
);
const cacheKey = vi.mocked(cache.set).mock.calls[0][0] as string;
expect(cacheKey).toMatch(
new RegExp(
`^watsonx:chat:${modelName}:${generateConfigHash(config)}:[a-f0-9]{64}:[a-f0-9]{64}$`,
),
);
expect(cacheKey).not.toContain(chatPrompt);
expect(cacheKey).not.toContain(config.apiKey);
});
it('should keep chat cache auth hash tied to the memoized client credentials', async () => {
const mockedWatsonXAIClient: Partial<any> = {
generateText: vi.fn(),
textChat: vi.fn().mockResolvedValue({
result: {
choices: [
{
message: {
role: 'assistant',
content: 'Memoized chat credential response',
},
finish_reason: 'stop',
},
],
usage: {
prompt_tokens: 5,
completion_tokens: 4,
total_tokens: 9,
},
},
}),
};
vi.mocked(WatsonXAI.newInstance).mockImplementation(function () {
return mockedWatsonXAIClient as any;
});
const cache: Partial<any> = {
get: vi.fn().mockResolvedValue(null),
set: vi.fn(),
};
vi.mocked(getCache).mockImplementation(function () {
return cache as any;
});
vi.mocked(isCacheEnabled).mockImplementation(function () {
return true;
});
const originalApiKey = 'watsonx-original-chat-env-api-key';
const rotatedApiKey = 'watsonx-rotated-chat-env-api-key';
const provider = new WatsonXChatProvider(modelName, {
config: { ...config, apiKey: undefined },
env: { WATSONX_AI_APIKEY: originalApiKey },
});
await provider.callApi(prompt);
provider.env = { WATSONX_AI_APIKEY: rotatedApiKey };
await provider.callApi(prompt);
const firstCacheKey = vi.mocked(cache.set).mock.calls[0][0] as string;
const secondCacheKey = vi.mocked(cache.set).mock.calls[1][0] as string;
expect(firstCacheKey).toEqual(secondCacheKey);
expect(firstCacheKey).not.toContain(originalApiKey);
expect(secondCacheKey).not.toContain(rotatedApiKey);
expect(WatsonXAI.newInstance).toHaveBeenCalledTimes(1);
expect(IamAuthenticator).toHaveBeenCalledWith({ apikey: originalApiKey });
expect(IamAuthenticator).not.toHaveBeenCalledWith({ apikey: rotatedApiKey });
});
it('should handle API errors gracefully', async () => {
const mockedWatsonXAIClient: Partial<any> = {
generateText: vi.fn(),
textChat: vi.fn().mockRejectedValue(new Error('Chat API error')),
};
vi.mocked(WatsonXAI.newInstance).mockImplementation(function () {
return mockedWatsonXAIClient as any;
});
const cache: Partial<any> = {
get: vi.fn().mockResolvedValue(null),
set: vi.fn(),
};
vi.mocked(getCache).mockImplementation(function () {
return cache as any;
});
vi.mocked(isCacheEnabled).mockImplementation(function () {
return true;
});
const provider = new WatsonXChatProvider(modelName, { config });
const response = await provider.callApi(prompt);
expect(response).toEqual({
error: 'API call error: Error: Chat API error',
output: '',
tokenUsage: createEmptyTokenUsage(),
});
expect(logger.error).toHaveBeenCalledWith(
'Watsonx Chat: API call error: Error: Chat API error',
);
});
it('should pass temperature and other parameters to textChat', async () => {
const configWithParams = {
...config,
temperature: 0.7,
topP: 0.9,
stopSequences: ['END'],
};
const mockedWatsonXAIClient: Partial<any> = {
generateText: vi.fn(),
textChat: vi.fn().mockResolvedValue({
result: {
choices: [
{
message: {
role: 'assistant',
content: 'Response',
},
finish_reason: 'stop',
},
],
usage: {
prompt_tokens: 5,
completion_tokens: 1,
total_tokens: 6,
},
},
}),
};
vi.mocked(WatsonXAI.newInstance).mockImplementation(function () {
return mockedWatsonXAIClient as any;
});
const cache: Partial<any> = {
get: vi.fn().mockResolvedValue(null),
set: vi.fn(),
};
vi.mocked(getCache).mockImplementation(function () {
return cache as any;
});
vi.mocked(isCacheEnabled).mockImplementation(function () {
return true;
});
const provider = new WatsonXChatProvider(modelName, { config: configWithParams });
await provider.callApi(prompt);
expect(mockedWatsonXAIClient.textChat).toHaveBeenCalledWith(
expect.objectContaining({
temperature: 0.7,
topP: 0.9,
stop: ['END'],
}),
);
});
});