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

2232 lines
65 KiB
TypeScript

// Request lifecycle tests: body determination, session handling, validation, transforms, token estimation, abort signals.
import './setup';
import path from 'path';
import dedent from 'dedent';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { fetchWithCache } from '../../../src/cache';
import cliState from '../../../src/cliState';
import { importModule } from '../../../src/esm';
import {
createSessionParser,
createValidateStatus,
determineRequestBody,
estimateTokenCount,
HttpProvider,
} from '../../../src/providers/http';
import { getRequestTimeoutMs } from '../../../src/providers/shared';
describe('determineRequestBody', () => {
it('should merge parsed prompt object with config body when content type is JSON', () => {
const result = determineRequestBody(
true,
{ promptField: 'test value' },
{ configField: 'config value' },
{ vars: 'not used in this case' },
);
expect(result).toEqual({
promptField: 'test value',
configField: 'config value',
});
});
it('should process JSON body with variables when parsed prompt is not an object', () => {
const result = determineRequestBody(
true,
'test prompt',
{ message: '{{ prompt }}' },
{ prompt: 'test prompt' },
);
expect(result).toEqual({
message: 'test prompt',
});
});
it('should process text body when content type is not JSON', () => {
const result = determineRequestBody(false, 'test prompt', 'Message: {{ prompt }}', {
prompt: 'test prompt',
});
expect(result).toBe('Message: test prompt');
});
it('should handle nested JSON structures', () => {
const result = determineRequestBody(
true,
'test prompt',
{
outer: {
inner: '{{ prompt }}',
static: 'value',
},
array: ['{{ prompt }}', 'static'],
},
{ prompt: 'test prompt' },
);
expect(result).toEqual({
outer: {
inner: 'test prompt',
static: 'value',
},
array: ['test prompt', 'static'],
});
});
it('should handle undefined config body with object prompt', () => {
const result = determineRequestBody(true, { message: 'test prompt' }, undefined, {});
expect(result).toEqual({
message: 'test prompt',
});
});
it('should handle array config body', () => {
const result = determineRequestBody(true, 'test prompt', ['static', '{{ prompt }}'], {
prompt: 'test prompt',
});
expect(result).toEqual(['static', 'test prompt']);
});
describe('stringified JSON body handling', () => {
it('should parse stringified JSON body when content type is JSON', () => {
// This simulates the bug case where body was saved as a string
const stringifiedBody = '{"message":"{{prompt}}"}';
const result = determineRequestBody(true, 'test prompt', stringifiedBody, {
prompt: 'test prompt',
});
expect(result).toEqual({
message: 'test prompt',
});
});
it('should parse stringified JSON body with nested objects', () => {
const stringifiedBody = '{"outer":{"inner":"{{prompt}}"}}';
const result = determineRequestBody(true, 'test prompt', stringifiedBody, {
prompt: 'test prompt',
});
expect(result).toEqual({
outer: {
inner: 'test prompt',
},
});
});
it('should parse stringified JSON array body', () => {
const stringifiedBody = '["{{prompt}}", "static"]';
const result = determineRequestBody(true, 'test prompt', stringifiedBody, {
prompt: 'test prompt',
});
expect(result).toEqual(['test prompt', 'static']);
});
it('should handle stringified JSON body that fails to parse as a template string', () => {
// This is a template string that looks like JSON but has invalid syntax after templating
const templateString = 'Message: {{prompt}}';
const result = determineRequestBody(true, 'test prompt', templateString, {
prompt: 'test prompt',
});
expect(result).toBe('Message: test prompt');
});
it('should not parse stringified body when content type is not JSON', () => {
const stringifiedBody = '{"message":"{{prompt}}"}';
const result = determineRequestBody(false, 'test prompt', stringifiedBody, {
prompt: 'test prompt',
});
// Should be treated as a template string, not parsed
expect(result).toBe('{"message":"test prompt"}');
});
it('should handle object body normally (backward compatibility)', () => {
// Ensure object bodies still work as before
const objectBody = { message: '{{prompt}}' };
const result = determineRequestBody(true, 'test prompt', objectBody, {
prompt: 'test prompt',
});
expect(result).toEqual({
message: 'test prompt',
});
});
it('should merge parsed prompt with stringified JSON body', () => {
const stringifiedBody = '{"configField":"value"}';
const result = determineRequestBody(
true,
{ promptField: 'prompt value' },
stringifiedBody,
{},
);
expect(result).toEqual({
configField: 'value',
promptField: 'prompt value',
});
});
});
});
describe('constructor validation', () => {
it('should validate config using Zod schema', () => {
expect(() => {
new HttpProvider('http://test.com', {
config: {
headers: { 'Content-Type': 123 }, // Invalid header type
},
});
}).toThrow(/expected string, received number/i);
});
it('should require body or GET method', () => {
expect(() => {
new HttpProvider('http://test.com', {
config: {
method: 'POST',
// Missing body
},
});
}).toThrow(/Expected HTTP provider http:\/\/test.com to have a config containing {body}/);
});
});
describe('content type handling', () => {
it('should handle JSON content type with object body', async () => {
const provider = new HttpProvider('http://test.com', {
config: {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: { key: '{{ prompt }}' },
},
});
const mockResponse = {
data: JSON.stringify({ result: 'success' }),
status: 200,
statusText: 'OK',
cached: false,
};
vi.mocked(fetchWithCache).mockResolvedValueOnce(mockResponse);
await provider.callApi('test');
expect(fetchWithCache).toHaveBeenCalledWith(
'http://test.com',
expect.objectContaining({
headers: expect.objectContaining({ 'content-type': 'application/json' }),
body: JSON.stringify({ key: 'test' }),
}),
expect.any(Number),
'text',
undefined,
undefined,
);
});
it('should handle non-JSON content type with string body', async () => {
const provider = new HttpProvider('http://test.com', {
config: {
method: 'POST',
headers: { 'Content-Type': 'text/plain' },
body: 'Raw text {{ prompt }}',
},
});
const mockResponse = {
data: 'success',
status: 200,
statusText: 'OK',
cached: false,
};
vi.mocked(fetchWithCache).mockResolvedValueOnce(mockResponse);
await provider.callApi('test');
expect(fetchWithCache).toHaveBeenCalledWith(
'http://test.com',
expect.objectContaining({
headers: expect.objectContaining({ 'content-type': 'text/plain' }),
body: 'Raw text test',
}),
expect.any(Number),
'text',
undefined,
undefined,
);
});
it('should throw error for object body with non-JSON content type', async () => {
const provider = new HttpProvider('http://test.com', {
config: {
method: 'POST',
headers: { 'Content-Type': 'text/plain' },
body: { key: 'value' },
},
});
await expect(provider.callApi('test')).rejects.toThrow(/Content-Type is not application\/json/);
});
});
describe('request transformation', () => {
it('should handle string-based request transform', async () => {
vi.clearAllMocks();
const provider = new HttpProvider('http://test.com', {
config: {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: { key: 'value' },
transformRequest: 'return { transformed: prompt.toLowerCase() }',
},
});
const mockResponse = {
data: JSON.stringify({ result: 'success' }),
status: 200,
statusText: 'OK',
cached: false,
};
vi.mocked(fetchWithCache).mockResolvedValueOnce(mockResponse);
await provider.callApi('TEST');
expect(fetchWithCache).toHaveBeenCalledWith(
'http://test.com',
{
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ key: 'value', transformed: 'test' }),
},
getRequestTimeoutMs(),
'text',
undefined,
undefined,
);
});
it('should handle function-based request transform', async () => {
vi.clearAllMocks();
const provider = new HttpProvider('http://test.com', {
config: {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: { key: 'value' },
transformRequest: (prompt: string) => ({ transformed: prompt.toUpperCase() }),
},
});
const mockResponse = {
data: JSON.stringify({ result: 'success' }),
status: 200,
statusText: 'OK',
cached: false,
};
vi.mocked(fetchWithCache).mockResolvedValueOnce(mockResponse);
await provider.callApi('test');
expect(fetchWithCache).toHaveBeenCalledWith(
'http://test.com',
{
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ key: 'value', transformed: 'TEST' }),
},
getRequestTimeoutMs(),
'text',
undefined,
undefined,
);
});
});
describe('response handling', () => {
it('should handle successful JSON response', async () => {
const provider = new HttpProvider('http://test.com', {
config: {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: { key: '{{ prompt }}' },
},
});
const mockResponse = {
data: JSON.stringify({ result: 'success' }),
status: 200,
statusText: 'OK',
cached: false,
headers: {},
};
vi.mocked(fetchWithCache).mockResolvedValueOnce(mockResponse);
const result = await provider.callApi('test');
expect(result.output).toEqual({ result: 'success' });
});
it('should handle non-JSON response', async () => {
const provider = new HttpProvider('http://test.com', {
config: {
method: 'POST',
headers: { 'Content-Type': 'text/plain' },
body: '{{ prompt }}',
},
});
const mockResponse = {
data: 'success',
status: 200,
statusText: 'OK',
cached: false,
headers: { 'Content-Type': 'text/plain' },
};
vi.mocked(fetchWithCache).mockResolvedValueOnce(mockResponse);
const result = await provider.callApi('test');
expect(result.output).toBe('success');
});
it('should include debug information when requested', async () => {
const mockUrl = 'http://example.com/api';
const mockHeaders = { 'content-type': 'application/json' };
const mockData = { result: 'success' };
// Mock the fetchWithCache response
vi.mocked(fetchWithCache).mockResolvedValueOnce({
data: mockData,
status: 200,
headers: mockHeaders,
statusText: 'OK',
cached: false,
});
const provider = new HttpProvider(mockUrl, {
config: {
method: 'GET',
},
});
const result = await provider.callApi('test', {
debug: true,
prompt: { raw: 'test', label: 'test' },
vars: {},
});
expect(result.metadata).toEqual({
http: {
headers: mockHeaders,
requestHeaders: {},
status: 200,
statusText: 'OK',
},
finalRequestBody: undefined,
transformedRequest: 'test',
});
expect(result.raw).toEqual(mockData);
});
it('should redact sensitive request body fields from debug metadata', async () => {
const provider = new HttpProvider('http://example.com/api', {
config: {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: {
message: '{{ prompt }}',
password: '{{ password }}',
nested: {
apiKey: '{{ apiKey }}',
},
},
},
});
vi.mocked(fetchWithCache).mockResolvedValueOnce({
data: { result: 'success' },
status: 200,
headers: { 'content-type': 'application/json' },
statusText: 'OK',
cached: false,
});
const result = await provider.callApi('hello', {
debug: true,
prompt: { raw: 'hello', label: 'hello' },
vars: {
password: 'plain-secret',
apiKey: 'sk-123456789012345678901234567890',
},
});
expect(result.metadata?.finalRequestBody).toEqual({
message: 'hello',
password: '[REDACTED]',
nested: {
apiKey: '[REDACTED]',
},
});
});
it('should redact sensitive transformed request object fields from debug metadata', async () => {
const provider = new HttpProvider('http://example.com/api', {
config: {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: { message: '{{ prompt }}' },
transformRequest: () => ({
message: 'hello',
password: 'plain-secret',
nested: {
apiKey: 'sk-123456789012345678901234567890',
},
}),
},
});
vi.mocked(fetchWithCache).mockResolvedValueOnce({
data: { result: 'success' },
status: 200,
headers: { 'content-type': 'application/json' },
statusText: 'OK',
cached: false,
});
const result = await provider.callApi('hello', {
debug: true,
prompt: { raw: 'hello', label: 'hello' },
vars: {},
});
expect(result.metadata?.transformedRequest).toEqual({
message: 'hello',
password: '[REDACTED]',
nested: {
apiKey: '[REDACTED]',
},
});
});
it('should handle plain text non-JSON responses', async () => {
const mockUrl = 'http://example.com/api';
const mockData = 'Not a JSON response';
// Mock the fetchWithCache response
vi.mocked(fetchWithCache).mockResolvedValueOnce({
data: mockData,
status: 200,
headers: {},
statusText: 'OK',
cached: false,
});
const provider = new HttpProvider(mockUrl, {
config: {
method: 'GET',
},
});
const result = await provider.callApi('test');
expect(result.output).toEqual(mockData);
});
it('should handle non-JSON responses with debug mode', async () => {
const mockUrl = 'http://example.com/api';
const mockHeaders = { 'content-type': 'text/plain' };
const mockData = 'text response';
// Mock the fetchWithCache response
vi.mocked(fetchWithCache).mockResolvedValueOnce({
data: mockData,
status: 200,
headers: mockHeaders,
statusText: 'OK',
cached: false,
});
const provider = new HttpProvider(mockUrl, {
config: {
method: 'GET',
transformResponse: () => ({ foo: 'bar' }),
},
});
const result = await provider.callApi('test', {
debug: true,
prompt: { raw: 'test', label: 'test' },
vars: {},
});
expect(result.raw).toEqual(mockData);
expect(result.metadata).toHaveProperty('http', {
headers: mockHeaders,
requestHeaders: {},
status: 200,
statusText: 'OK',
});
expect(result.output).toEqual({ foo: 'bar' });
});
it('should handle transformResponse returning a simple string value', async () => {
const mockUrl = 'http://example.com/api';
const mockData = { result: 'success' };
// Mock the fetchWithCache response
vi.mocked(fetchWithCache).mockResolvedValueOnce({
data: mockData,
status: 200,
headers: {},
statusText: 'OK',
cached: false,
});
// Create a transform that returns a simple string
const transformResponse = () => 'transformed result';
const provider = new HttpProvider(mockUrl, {
config: {
method: 'GET',
transformResponse,
},
});
const result = await provider.callApi('test');
// Verify the result is correctly structured
expect(result.output).toBe('transformed result');
});
it('should handle non-JSON responses with debug mode and transform without output property', async () => {
const mockUrl = 'http://example.com/api';
const mockHeaders = { 'content-type': 'text/plain' };
const mockData = 'text response';
// Mock the fetchWithCache response
vi.mocked(fetchWithCache).mockResolvedValueOnce({
data: mockData,
status: 200,
headers: mockHeaders,
statusText: 'OK',
cached: false,
});
// Setup provider with transform that doesn't return an output property
const provider = new HttpProvider(mockUrl, {
config: {
method: 'GET',
transformResponse: () => ({ transformed: true }),
},
});
// Call with debug mode
const result = await provider.callApi('test', {
debug: true,
prompt: { raw: 'test', label: 'test' },
vars: {},
});
// Verify transformed response and debug info
expect(result.output).toEqual({ transformed: true });
expect(result.raw).toEqual(mockData);
expect(result.metadata).toHaveProperty('http', {
headers: mockHeaders,
requestHeaders: {},
status: 200,
statusText: 'OK',
});
});
});
describe('session handling', () => {
it('should extract session ID from headers when configured', async () => {
const sessionParser = vi.fn().mockReturnValue('test-session');
const provider = new HttpProvider('http://test.com', {
config: {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: { key: '{{ prompt }}' },
sessionParser,
},
});
const mockResponse = {
data: JSON.stringify({ result: 'success' }),
status: 200,
statusText: 'OK',
cached: false,
headers: { 'x-session-id': 'test-session' },
};
vi.mocked(fetchWithCache).mockResolvedValueOnce(mockResponse);
const result = await provider.callApi('test');
expect(result.sessionId).toBe('test-session');
expect(sessionParser).toHaveBeenCalledWith({
headers: mockResponse.headers,
body: { result: 'success' },
});
});
it('should include sessionId in response when returned by parser', async () => {
const mockUrl = 'http://example.com/api';
const mockSessionId = 'test-session-123';
// Mock the fetchWithCache response
vi.mocked(fetchWithCache).mockResolvedValueOnce({
data: { result: 'success' },
status: 200,
headers: { 'session-id': mockSessionId },
statusText: 'OK',
cached: false,
});
// Create a session parser that returns the session ID
const sessionParser = () => mockSessionId;
const provider = new HttpProvider(mockUrl, {
config: {
method: 'GET',
sessionParser,
},
});
const result = await provider.callApi('test');
// Verify the sessionId is included in the response
expect(result.sessionId).toBe(mockSessionId);
});
});
describe('session endpoint', () => {
it('should fetch session from endpoint on first call', async () => {
const provider = new HttpProvider('http://test.com/api', {
config: {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: { query: '{{ prompt }}', session: '{{ sessionId }}' },
session: {
url: 'http://test.com/auth/session',
method: 'POST',
headers: { Authorization: 'Bearer test-token' },
responseParser: 'data.body.sessionId',
},
},
});
// First call: session endpoint
vi.mocked(fetchWithCache).mockResolvedValueOnce({
data: JSON.stringify({ sessionId: 'session-abc-123' }),
status: 200,
statusText: 'OK',
cached: false,
headers: {},
});
// Second call: main API
vi.mocked(fetchWithCache).mockResolvedValueOnce({
data: JSON.stringify({ result: 'success' }),
status: 200,
statusText: 'OK',
cached: false,
headers: {},
});
const result = await provider.callApi('test query');
// Verify session endpoint was called
expect(fetchWithCache).toHaveBeenCalledTimes(2);
const sessionCall = vi.mocked(fetchWithCache).mock.calls[0];
expect(sessionCall).toBeDefined();
expect(sessionCall?.[0]).toBe('http://test.com/auth/session');
expect(sessionCall?.[1]?.method).toBe('POST');
const sessionHeaders = sessionCall?.[1]?.headers as Record<string, string>;
expect(sessionHeaders?.authorization).toBe('Bearer test-token');
// Verify main API was called with sessionId in body
const mainCall = vi.mocked(fetchWithCache).mock.calls[1];
expect(mainCall).toBeDefined();
expect(mainCall?.[0]).toBe('http://test.com/api');
const mainBody = JSON.parse(mainCall?.[1]?.body as string);
expect(mainBody.session).toBe('session-abc-123');
// Verify sessionId is returned in response
expect(result.sessionId).toBe('session-abc-123');
});
it('should reuse session when context contains our fetched sessionId (Hydra pattern)', async () => {
const provider = new HttpProvider('http://test.com/api', {
config: {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: { query: '{{ prompt }}', session: '{{ sessionId }}' },
session: {
url: 'http://test.com/auth/session',
method: 'POST',
responseParser: 'data.body.sessionId',
},
},
});
// First call: fetch session
vi.mocked(fetchWithCache).mockResolvedValueOnce({
data: JSON.stringify({ sessionId: 'session-hydra-1' }),
status: 200,
statusText: 'OK',
cached: false,
headers: {},
});
vi.mocked(fetchWithCache).mockResolvedValueOnce({
data: JSON.stringify({ result: 'turn 1' }),
status: 200,
statusText: 'OK',
cached: false,
headers: {},
});
const result1 = await provider.callApi('turn 1');
expect(result1.sessionId).toBe('session-hydra-1');
// Second call: pass the sessionId we got back (simulating Hydra behavior)
vi.mocked(fetchWithCache).mockResolvedValueOnce({
data: JSON.stringify({ result: 'turn 2' }),
status: 200,
statusText: 'OK',
cached: false,
headers: {},
});
const result2 = await provider.callApi('turn 2', {
vars: { sessionId: 'session-hydra-1' },
} as any);
// Should NOT call session endpoint again (only 3 total calls, not 4)
expect(fetchWithCache).toHaveBeenCalledTimes(3);
// Verify same sessionId is used
const mainCall2 = vi.mocked(fetchWithCache).mock.calls[2];
expect(mainCall2).toBeDefined();
const mainBody2 = JSON.parse(mainCall2?.[1]?.body as string);
expect(mainBody2.session).toBe('session-hydra-1');
expect(result2.sessionId).toBe('session-hydra-1');
});
it('should fetch fresh session when context contains unknown sessionId (Meta-agent pattern)', async () => {
const provider = new HttpProvider('http://test.com/api', {
config: {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: { query: '{{ prompt }}', session: '{{ sessionId }}' },
session: {
url: 'http://test.com/auth/session',
method: 'POST',
responseParser: 'data.body.sessionId',
},
},
});
// First call with client-generated UUID
vi.mocked(fetchWithCache).mockResolvedValueOnce({
data: JSON.stringify({ sessionId: 'session-meta-1' }),
status: 200,
statusText: 'OK',
cached: false,
headers: {},
});
vi.mocked(fetchWithCache).mockResolvedValueOnce({
data: JSON.stringify({ result: 'turn 1' }),
status: 200,
statusText: 'OK',
cached: false,
headers: {},
});
const result1 = await provider.callApi('turn 1', {
vars: { sessionId: 'client-uuid-111' }, // Client-generated UUID
} as any);
expect(result1.sessionId).toBe('session-meta-1');
// Second call with different client-generated UUID
vi.mocked(fetchWithCache).mockResolvedValueOnce({
data: JSON.stringify({ sessionId: 'session-meta-2' }),
status: 200,
statusText: 'OK',
cached: false,
headers: {},
});
vi.mocked(fetchWithCache).mockResolvedValueOnce({
data: JSON.stringify({ result: 'turn 2' }),
status: 200,
statusText: 'OK',
cached: false,
headers: {},
});
const result2 = await provider.callApi('turn 2', {
vars: { sessionId: 'client-uuid-222' }, // Different client-generated UUID
} as any);
// Should call session endpoint again (4 total calls)
expect(fetchWithCache).toHaveBeenCalledTimes(4);
expect(result2.sessionId).toBe('session-meta-2');
});
it('should throw error when session endpoint fails', async () => {
const provider = new HttpProvider('http://test.com/api', {
config: {
method: 'POST',
body: { query: '{{ prompt }}' },
session: {
url: 'http://test.com/auth/session',
responseParser: 'data.body.sessionId',
},
},
});
vi.mocked(fetchWithCache).mockResolvedValueOnce({
data: 'Unauthorized',
status: 401,
statusText: 'Unauthorized',
cached: false,
headers: {},
});
await expect(provider.callApi('test')).rejects.toThrow(
'Session endpoint request failed with status 401 Unauthorized',
);
});
it('should throw error when session endpoint returns no sessionId', async () => {
const provider = new HttpProvider('http://test.com/api', {
config: {
method: 'POST',
body: { query: '{{ prompt }}' },
session: {
url: 'http://test.com/auth/session',
responseParser: 'data.body.sessionId',
},
},
});
vi.mocked(fetchWithCache).mockResolvedValueOnce({
data: JSON.stringify({ error: 'no session created' }),
status: 200,
statusText: 'OK',
cached: false,
headers: {},
});
await expect(provider.callApi('test')).rejects.toThrow(
'Session endpoint did not return a session ID',
);
});
it('should support GET method for session endpoint', async () => {
const provider = new HttpProvider('http://test.com/api', {
config: {
method: 'POST',
body: { query: '{{ prompt }}' },
session: {
url: 'http://test.com/auth/session?client_id=123',
method: 'GET',
responseParser: 'data.body.session_token',
},
},
});
vi.mocked(fetchWithCache).mockResolvedValueOnce({
data: JSON.stringify({ session_token: 'get-session-xyz' }),
status: 200,
statusText: 'OK',
cached: false,
headers: {},
});
vi.mocked(fetchWithCache).mockResolvedValueOnce({
data: JSON.stringify({ result: 'success' }),
status: 200,
statusText: 'OK',
cached: false,
headers: {},
});
await provider.callApi('test');
const sessionCall = vi.mocked(fetchWithCache).mock.calls[0];
expect(sessionCall).toBeDefined();
expect(sessionCall?.[1]?.method).toBe('GET');
expect(sessionCall?.[1]?.body).toBeUndefined();
});
it('should render template variables in session endpoint config', async () => {
const provider = new HttpProvider('http://test.com/api', {
config: {
method: 'POST',
body: { query: '{{ prompt }}' },
session: {
url: 'http://test.com/auth/session',
method: 'POST',
headers: { Authorization: 'Bearer {{ api_key }}' },
body: { client_id: '{{ client_id }}' },
responseParser: 'data.body.sessionId',
},
},
});
vi.mocked(fetchWithCache).mockResolvedValueOnce({
data: JSON.stringify({ sessionId: 'templated-session' }),
status: 200,
statusText: 'OK',
cached: false,
headers: {},
});
vi.mocked(fetchWithCache).mockResolvedValueOnce({
data: JSON.stringify({ result: 'success' }),
status: 200,
statusText: 'OK',
cached: false,
headers: {},
});
await provider.callApi('test', {
vars: { api_key: 'secret-key', client_id: 'my-client' },
} as any);
const sessionCall = vi.mocked(fetchWithCache).mock.calls[0];
expect(sessionCall).toBeDefined();
const sessionHeaders = sessionCall?.[1]?.headers as Record<string, string>;
expect(sessionHeaders?.authorization).toBe('Bearer secret-key');
expect(JSON.parse(sessionCall?.[1]?.body as string)).toEqual({ client_id: 'my-client' });
});
it('should extract session from headers using responseParser', async () => {
const provider = new HttpProvider('http://test.com/api', {
config: {
method: 'POST',
body: { query: '{{ prompt }}' },
session: {
url: 'http://test.com/auth/session',
responseParser: 'data.headers["x-session-token"]',
},
},
});
vi.mocked(fetchWithCache).mockResolvedValueOnce({
data: '',
status: 200,
statusText: 'OK',
cached: false,
headers: { 'x-session-token': 'header-session-456' },
});
vi.mocked(fetchWithCache).mockResolvedValueOnce({
data: JSON.stringify({ result: 'success' }),
status: 200,
statusText: 'OK',
cached: false,
headers: {},
});
const result = await provider.callApi('test');
expect(result.sessionId).toBe('header-session-456');
});
});
describe('error handling', () => {
it('should throw error for responses that fail validateStatus check', async () => {
const provider = new HttpProvider('http://test.com', {
config: {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: { key: '{{ prompt }}' },
validateStatus: 'status >= 200 && status < 300', // Only accept 2xx responses
},
});
const mockResponse = {
data: 'Error message',
status: 400,
statusText: 'Bad Request',
cached: false,
};
vi.mocked(fetchWithCache).mockResolvedValueOnce(mockResponse);
await expect(provider.callApi('test')).rejects.toThrow(
'HTTP call failed with status 400 Bad Request: Error message',
);
});
it('should throw session parsing errors', async () => {
const sessionParser = vi.fn().mockImplementation(function () {
throw new Error('Session parsing failed');
});
const provider = new HttpProvider('http://test.com', {
config: {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: { key: '{{ prompt }}' },
sessionParser,
},
});
const mockResponse = {
data: JSON.stringify({ result: 'success' }),
status: 200,
statusText: 'OK',
cached: false,
headers: { 'x-session-id': 'test-session' },
};
vi.mocked(fetchWithCache).mockResolvedValueOnce(mockResponse);
await expect(provider.callApi('test')).rejects.toThrow('Session parsing failed');
});
it('should throw error for raw request with non-200 response', async () => {
const provider = new HttpProvider('http://test.com', {
config: {
request: dedent`
GET /api/data HTTP/1.1
Host: example.com
`,
validateStatus: (status: number) => status < 500,
},
});
const mockResponse = {
data: 'Error occurred',
status: 500,
statusText: 'Internal Server Error',
cached: false,
};
vi.mocked(fetchWithCache).mockResolvedValueOnce(mockResponse);
await expect(provider.callApi('test')).rejects.toThrow(
'HTTP call failed with status 500 Internal Server Error: Error occurred',
);
});
});
describe('validateStatus', () => {
describe('default behavior', () => {
it('should accept all status codes when validateStatus is not provided', async () => {
const provider = new HttpProvider('http://test.com', {
config: {
method: 'POST',
body: { key: 'value' },
},
});
// Test various status codes
const testCases = [
{ status: 200, statusText: 'OK' },
{ status: 400, statusText: 'Bad Request' },
{ status: 500, statusText: 'Server Error' },
];
for (const { status, statusText } of testCases) {
const mockResponse = {
data: JSON.stringify({ result: 'success' }),
status,
statusText,
cached: false,
};
vi.mocked(fetchWithCache).mockResolvedValueOnce(mockResponse);
const result = await provider.callApi('test');
expect(result.output).toEqual({ result: 'success' });
}
});
});
describe('string-based validators', () => {
it('should handle expression format', async () => {
const provider = new HttpProvider('http://test.com', {
config: {
method: 'POST',
body: { key: 'value' },
validateStatus: 'status >= 200 && status < 300',
},
});
// Test successful case
const mockResponse = {
data: JSON.stringify({ result: 'success' }),
status: 201,
statusText: 'Created',
cached: false,
};
vi.mocked(fetchWithCache).mockResolvedValueOnce(mockResponse);
const result = await provider.callApi('test');
expect(result.output).toEqual({ result: 'success' });
// Test failure case
const errorResponse = {
data: 'Error message',
status: 400,
statusText: 'Bad Request',
cached: false,
};
vi.mocked(fetchWithCache).mockResolvedValueOnce(errorResponse);
await expect(provider.callApi('test')).rejects.toThrow(
'HTTP call failed with status 400 Bad Request: Error message',
);
});
it('should handle arrow function format with parameter', async () => {
const provider = new HttpProvider('http://test.com', {
config: {
method: 'POST',
body: { key: 'value' },
validateStatus: '(s) => s < 500',
},
});
// Test accepting 4xx status
const mockResponse = {
data: JSON.stringify({ result: 'success' }),
status: 404,
statusText: 'Not Found',
cached: false,
};
vi.mocked(fetchWithCache).mockResolvedValueOnce(mockResponse);
const result = await provider.callApi('test');
expect(result.output).toEqual({ result: 'success' });
// Test rejecting 5xx status
const errorResponse = {
data: 'Error message',
status: 500,
statusText: 'Server Error',
cached: false,
};
vi.mocked(fetchWithCache).mockResolvedValueOnce(errorResponse);
await expect(provider.callApi('test')).rejects.toThrow(
'HTTP call failed with status 500 Server Error: Error message',
);
});
it('should handle arrow function format without parameter', async () => {
const provider = new HttpProvider('http://test.com', {
config: {
method: 'POST',
body: { key: 'value' },
validateStatus: '() => true',
},
});
// Test accepting all status codes
const responses = [
{ status: 200, statusText: 'OK' },
{ status: 404, statusText: 'Not Found' },
{ status: 500, statusText: 'Server Error' },
];
for (const { status, statusText } of responses) {
const mockResponse = {
data: JSON.stringify({ result: 'success' }),
status,
statusText,
cached: false,
};
vi.mocked(fetchWithCache).mockResolvedValueOnce(mockResponse);
const result = await provider.callApi('test');
expect(result.output).toEqual({ result: 'success' });
}
});
it('should handle regular function format', async () => {
const provider = new HttpProvider('http://test.com', {
config: {
method: 'POST',
body: { key: 'value' },
validateStatus: 'function(status) { return status < 500; }',
},
});
// Test accepting 4xx status
const mockResponse = {
data: JSON.stringify({ result: 'success' }),
status: 404,
statusText: 'Not Found',
cached: false,
};
vi.mocked(fetchWithCache).mockResolvedValueOnce(mockResponse);
const result = await provider.callApi('test');
expect(result.output).toEqual({ result: 'success' });
// Test rejecting 5xx status
const errorResponse = {
data: 'Error message',
status: 500,
statusText: 'Server Error',
cached: false,
};
vi.mocked(fetchWithCache).mockResolvedValueOnce(errorResponse);
await expect(provider.callApi('test')).rejects.toThrow(
'HTTP call failed with status 500 Server Error: Error message',
);
});
});
describe('error handling', () => {
it('should handle malformed string expressions', async () => {
const provider = new HttpProvider('http://test.com', {
config: {
method: 'POST',
body: { key: 'value' },
validateStatus: 'invalid[syntax',
},
});
const mockResponse = {
data: 'response',
status: 200,
statusText: 'OK',
cached: false,
};
vi.mocked(fetchWithCache).mockResolvedValueOnce(mockResponse);
await expect(provider.callApi('test')).rejects.toThrow('Invalid status validator expression');
});
it('should throw error for malformed file-based validator', async () => {
vi.mocked(importModule).mockRejectedValueOnce(new Error('Module not found'));
await expect(createValidateStatus('file://invalid-validator.js')).rejects.toThrow(
/Status validator malformed/,
);
});
it('should throw error for unsupported validator type', async () => {
await expect(createValidateStatus(123 as any)).rejects.toThrow(
'Unsupported status validator type: number',
);
});
});
describe('file-based validators', () => {
it('should handle file-based validateStatus', async () => {
const mockValidator = vi.fn((status) => status < 500);
vi.mocked(importModule).mockResolvedValueOnce(mockValidator);
const provider = new HttpProvider('http://test.com', {
config: {
method: 'POST',
body: { key: 'value' },
validateStatus: 'file://validator.js',
},
});
const mockResponse = {
data: JSON.stringify({ result: 'success' }),
status: 404,
statusText: 'Not Found',
cached: false,
};
vi.mocked(fetchWithCache).mockResolvedValueOnce(mockResponse);
const result = await provider.callApi('test');
expect(result.output).toEqual({ result: 'success' });
expect(importModule).toHaveBeenCalledWith(
path.resolve('/mock/base/path', 'validator.js'),
undefined,
);
expect(mockValidator).toHaveBeenCalledWith(404);
});
it('should handle file-based validateStatus with specific function', async () => {
const mockValidator = vi.fn((status) => status < 500);
vi.mocked(importModule).mockResolvedValueOnce(mockValidator);
const provider = new HttpProvider('http://test.com', {
config: {
method: 'POST',
body: { key: 'value' },
validateStatus: 'file://validator.js:validateStatus',
},
});
const mockResponse = {
data: JSON.stringify({ result: 'success' }),
status: 404,
statusText: 'Not Found',
cached: false,
};
vi.mocked(fetchWithCache).mockResolvedValueOnce(mockResponse);
const result = await provider.callApi('test');
expect(result.output).toEqual({ result: 'success' });
expect(importModule).toHaveBeenCalledWith(
path.resolve('/mock/base/path', 'validator.js'),
'validateStatus',
);
expect(mockValidator).toHaveBeenCalledWith(404);
});
});
});
describe('session parser', () => {
it('should handle string-based session parser', async () => {
const provider = new HttpProvider('http://test.com', {
config: {
method: 'GET',
sessionParser: 'data.headers["x-session-id"]',
},
});
const mockResponse = {
data: 'response',
status: 200,
statusText: 'OK',
cached: false,
headers: { 'x-session-id': 'test-session' },
};
vi.mocked(fetchWithCache).mockResolvedValueOnce(mockResponse);
const result = await provider.callApi('test');
expect(result.sessionId).toBe('test-session');
});
it('should throw error for unsupported session parser type', async () => {
await expect(createSessionParser(123 as any)).rejects.toThrow(
'Unsupported response transform type: number',
);
});
});
describe('transform response error handling', () => {
it('should handle errors in response transform function', async () => {
const provider = new HttpProvider('http://test.com', {
config: {
method: 'GET',
transformResponse: () => {
throw new Error('Transform failed');
},
},
});
const mockResponse = {
data: 'response',
status: 200,
statusText: 'OK',
cached: false,
};
vi.mocked(fetchWithCache).mockResolvedValueOnce(mockResponse);
await expect(provider.callApi('test')).rejects.toThrow('Transform failed');
});
it('should handle errors in string-based transform', async () => {
const provider = new HttpProvider('http://test.com', {
config: {
method: 'GET',
transformResponse: 'invalid.syntax[',
},
});
const mockResponse = {
data: 'response',
status: 200,
statusText: 'OK',
cached: false,
};
vi.mocked(fetchWithCache).mockResolvedValueOnce(mockResponse);
await expect(provider.callApi('test')).rejects.toThrow('Failed to transform response');
});
});
describe('arrow function parsing in transformResponse', () => {
it('should handle arrow function with explicit body', async () => {
const provider = new HttpProvider('http://test.com', {
config: {
method: 'GET',
transformResponse: '(json, text) => { return json.data }',
},
});
const mockResponse = {
data: JSON.stringify({ data: 'test value' }),
status: 200,
statusText: 'OK',
cached: false,
};
vi.mocked(fetchWithCache).mockResolvedValueOnce(mockResponse);
const result = await provider.callApi('test');
expect(result.output).toBe('test value');
});
it('should handle regular function with explicit body', async () => {
const provider = new HttpProvider('http://test.com', {
config: {
method: 'GET',
transformResponse: 'function(json, text) { return json.data }',
},
});
const mockResponse = {
data: JSON.stringify({ data: 'test value' }),
status: 200,
statusText: 'OK',
cached: false,
};
vi.mocked(fetchWithCache).mockResolvedValueOnce(mockResponse);
const result = await provider.callApi('test');
expect(result.output).toBe('test value');
});
it('should handle arrow function with implicit return', async () => {
const provider = new HttpProvider('http://test.com', {
config: {
method: 'GET',
transformResponse: '(json) => json.data',
},
});
const mockResponse = {
data: JSON.stringify({ data: 'test value' }),
status: 200,
statusText: 'OK',
cached: false,
};
vi.mocked(fetchWithCache).mockResolvedValueOnce(mockResponse);
const result = await provider.callApi('test');
expect(result.output).toBe('test value');
});
it('should handle arrow function with context parameter', async () => {
const provider = new HttpProvider('http://test.com', {
config: {
method: 'GET',
transformResponse: '(json, text, context) => context.response.status',
},
});
const mockResponse = {
data: 'response',
status: 200,
statusText: 'OK',
cached: false,
};
vi.mocked(fetchWithCache).mockResolvedValueOnce(mockResponse);
const result = await provider.callApi('test');
expect(result.output).toBe(200);
});
it('should handle simple expression without function', async () => {
const provider = new HttpProvider('http://test.com', {
config: {
method: 'GET',
transformResponse: 'json.data',
},
});
const mockResponse = {
data: JSON.stringify({ data: 'test value' }),
status: 200,
statusText: 'OK',
cached: false,
};
vi.mocked(fetchWithCache).mockResolvedValueOnce(mockResponse);
const result = await provider.callApi('test');
expect(result.output).toBe('test value');
});
it('should handle multiline arrow function', async () => {
const provider = new HttpProvider('http://test.com', {
config: {
method: 'GET',
transformResponse: `(json, text) => {
const value = json.data;
return value.toUpperCase();
}`,
},
});
const mockResponse = {
data: JSON.stringify({ data: 'test value' }),
status: 200,
statusText: 'OK',
cached: false,
};
vi.mocked(fetchWithCache).mockResolvedValueOnce(mockResponse);
const result = await provider.callApi('test');
expect(result.output).toBe('TEST VALUE');
});
});
describe('transform request error handling', () => {
it('should handle errors in string-based request transform', async () => {
const provider = new HttpProvider('http://test.com', {
config: {
method: 'POST',
body: { key: 'value' },
transformRequest: 'invalid.syntax[',
},
});
await expect(provider.callApi('test')).rejects.toThrow('Unexpected token');
});
});
describe('status validator error handling', () => {
it('should throw error for invalid status validator expression', async () => {
const provider = new HttpProvider('http://test.com', {
config: {
method: 'GET',
validateStatus: 'invalid syntax[',
},
});
const mockResponse = {
data: 'response',
status: 200,
statusText: 'OK',
cached: false,
};
vi.mocked(fetchWithCache).mockResolvedValueOnce(mockResponse);
await expect(provider.callApi('test')).rejects.toThrow('Invalid status validator expression');
});
it('should throw error for malformed file-based validator', async () => {
vi.mocked(importModule).mockRejectedValueOnce(new Error('Module not found'));
await expect(createValidateStatus('file://invalid-validator.js')).rejects.toThrow(
/Status validator malformed/,
);
});
it('should throw error for unsupported validator type', async () => {
await expect(createValidateStatus(123 as any)).rejects.toThrow(
'Unsupported status validator type: number',
);
});
});
describe('string-based validators', () => {
it('should handle expression format', async () => {
const provider = new HttpProvider('http://test.com', {
config: {
method: 'POST',
body: { key: 'value' },
validateStatus: 'status >= 200 && status < 300',
},
});
const mockResponse = {
data: JSON.stringify({ result: 'success' }),
status: 201,
statusText: 'Created',
cached: false,
};
vi.mocked(fetchWithCache).mockResolvedValueOnce(mockResponse);
const result = await provider.callApi('test');
expect(result.output).toEqual({ result: 'success' });
const errorResponse = {
data: 'Error message',
status: 400,
statusText: 'Bad Request',
cached: false,
};
vi.mocked(fetchWithCache).mockResolvedValueOnce(errorResponse);
await expect(provider.callApi('test')).rejects.toThrow(
'HTTP call failed with status 400 Bad Request: Error message',
);
});
it('should handle arrow function format with parameter', async () => {
const provider = new HttpProvider('http://test.com', {
config: {
method: 'POST',
body: { key: 'value' },
validateStatus: '(s) => s < 500',
},
});
const mockResponse = {
data: JSON.stringify({ result: 'success' }),
status: 404,
statusText: 'Not Found',
cached: false,
};
vi.mocked(fetchWithCache).mockResolvedValueOnce(mockResponse);
const result = await provider.callApi('test');
expect(result.output).toEqual({ result: 'success' });
const errorResponse = {
data: 'Error message',
status: 500,
statusText: 'Server Error',
cached: false,
};
vi.mocked(fetchWithCache).mockResolvedValueOnce(errorResponse);
await expect(provider.callApi('test')).rejects.toThrow(
'HTTP call failed with status 500 Server Error: Error message',
);
});
it('should handle arrow function format without parameter', async () => {
const provider = new HttpProvider('http://test.com', {
config: {
method: 'POST',
body: { key: 'value' },
validateStatus: '() => true',
},
});
const responses = [
{ status: 200, statusText: 'OK' },
{ status: 404, statusText: 'Not Found' },
{ status: 500, statusText: 'Server Error' },
];
for (const { status, statusText } of responses) {
const mockResponse = {
data: JSON.stringify({ result: 'success' }),
status,
statusText,
cached: false,
};
vi.mocked(fetchWithCache).mockResolvedValueOnce(mockResponse);
const result = await provider.callApi('test');
expect(result.output).toEqual({ result: 'success' });
}
});
it('should handle regular function format', async () => {
const provider = new HttpProvider('http://test.com', {
config: {
method: 'POST',
body: { key: 'value' },
validateStatus: 'function(status) { return status < 500; }',
},
});
const mockResponse = {
data: JSON.stringify({ result: 'success' }),
status: 404,
statusText: 'Not Found',
cached: false,
};
vi.mocked(fetchWithCache).mockResolvedValueOnce(mockResponse);
const result = await provider.callApi('test');
expect(result.output).toEqual({ result: 'success' });
const errorResponse = {
data: 'Error message',
status: 500,
statusText: 'Server Error',
cached: false,
};
vi.mocked(fetchWithCache).mockResolvedValueOnce(errorResponse);
await expect(provider.callApi('test')).rejects.toThrow(
'HTTP call failed with status 500 Server Error: Error message',
);
});
it('should handle malformed string expressions', async () => {
const provider = new HttpProvider('http://test.com', {
config: {
method: 'POST',
body: { key: 'value' },
validateStatus: 'invalid[syntax',
},
});
const mockResponse = {
data: 'response',
status: 200,
statusText: 'OK',
cached: false,
};
vi.mocked(fetchWithCache).mockResolvedValueOnce(mockResponse);
await expect(provider.callApi('test')).rejects.toThrow('Invalid status validator expression');
});
});
describe('HttpProvider with token estimation', () => {
beforeEach(() => {
cliState.config = {} as any;
});
afterEach(() => {
delete cliState.config;
});
it('should not estimate tokens when disabled', async () => {
const provider = new HttpProvider('http://test.com', {
config: {
method: 'POST',
body: { prompt: '{{prompt}}' },
// tokenEstimation not configured, should be disabled by default
},
});
const mockResponse = {
data: JSON.stringify({ result: 'Hello world' }),
status: 200,
statusText: 'OK',
cached: false,
};
vi.mocked(fetchWithCache).mockResolvedValueOnce(mockResponse);
const result = await provider.callApi('Test prompt');
expect(result.tokenUsage).toEqual(
expect.objectContaining({
numRequests: 1,
}),
);
});
it('should enable token estimation by default in redteam mode', async () => {
cliState.config = { redteam: {} } as any;
const provider = new HttpProvider('http://test.com', {
config: {
method: 'POST',
body: { prompt: '{{prompt}}' },
},
});
const mockResponse = {
data: JSON.stringify({ result: 'Hello world' }),
status: 200,
statusText: 'OK',
cached: false,
};
vi.mocked(fetchWithCache).mockResolvedValueOnce(mockResponse);
const result = await provider.callApi('Test prompt');
expect(result.tokenUsage).toBeDefined();
expect(result.tokenUsage!.prompt).toBe(Math.ceil(2 * 1.3));
expect(result.tokenUsage!.completion).toBe(Math.ceil(2 * 1.3));
expect(result.tokenUsage!.total).toBe(
result.tokenUsage!.prompt! + result.tokenUsage!.completion!,
);
});
it('should estimate tokens when enabled with default settings', async () => {
const provider = new HttpProvider('http://test.com', {
config: {
method: 'POST',
body: { prompt: '{{prompt}}' },
tokenEstimation: {
enabled: true,
},
},
});
const mockResponse = {
data: JSON.stringify({ result: 'Hello world response' }),
status: 200,
statusText: 'OK',
cached: false,
};
vi.mocked(fetchWithCache).mockResolvedValueOnce(mockResponse);
const result = await provider.callApi('Test prompt here');
expect(result.tokenUsage).toBeDefined();
expect(result.tokenUsage!.prompt).toBe(Math.ceil(3 * 1.3)); // "Test prompt here" = 3 words * 1.3
expect(result.tokenUsage!.completion).toBe(Math.ceil(3 * 1.3)); // "Hello world response" = 3 words * 1.3
expect(result.tokenUsage!.total).toBe(
result.tokenUsage!.prompt! + result.tokenUsage!.completion!,
);
expect(result.tokenUsage!.numRequests).toBe(1);
});
it('should use custom multiplier', async () => {
const provider = new HttpProvider('http://test.com', {
config: {
method: 'POST',
body: { prompt: '{{prompt}}' },
tokenEstimation: {
enabled: true,
multiplier: 2.0,
},
},
});
const mockResponse = {
data: 'Simple response', // Plain text response
status: 200,
statusText: 'OK',
cached: false,
};
vi.mocked(fetchWithCache).mockResolvedValueOnce(mockResponse);
const result = await provider.callApi('Hello world');
expect(result.tokenUsage!.prompt).toBe(Math.ceil(2 * 2.0)); // 2 words * 2.0 = 4
expect(result.tokenUsage!.completion).toBe(Math.ceil(2 * 2.0)); // 2 words * 2.0 = 4
expect(result.tokenUsage!.total).toBe(8);
});
it('should not override existing tokenUsage from transformResponse', async () => {
const provider = new HttpProvider('http://test.com', {
config: {
method: 'POST',
body: { prompt: '{{prompt}}' },
tokenEstimation: {
enabled: true,
},
transformResponse: () => ({
output: 'Test response',
tokenUsage: {
prompt: 100,
completion: 200,
total: 300,
},
}),
},
});
const mockResponse = {
data: JSON.stringify({ result: 'Hello world' }),
status: 200,
statusText: 'OK',
cached: false,
};
vi.mocked(fetchWithCache).mockResolvedValueOnce(mockResponse);
const result = await provider.callApi('Test prompt');
// Should use the tokenUsage from transformResponse, not estimation
expect(result.tokenUsage!.prompt).toBe(100);
expect(result.tokenUsage!.completion).toBe(200);
expect(result.tokenUsage!.total).toBe(300);
});
it('should work with raw request mode', async () => {
const provider = new HttpProvider('http://test.com', {
config: {
request: dedent`
POST /api HTTP/1.1
Host: test.com
Content-Type: application/json
{"prompt": "{{prompt}}"}
`,
tokenEstimation: {
enabled: true,
},
},
});
const mockResponse = {
data: JSON.stringify({ message: 'Success response' }),
status: 200,
statusText: 'OK',
cached: false,
};
vi.mocked(fetchWithCache).mockResolvedValueOnce(mockResponse);
const result = await provider.callApi('Hello world');
expect(result.tokenUsage).toBeDefined();
expect(result.tokenUsage!.prompt).toBeGreaterThan(0);
expect(result.tokenUsage!.completion).toBeGreaterThan(0);
expect(result.tokenUsage!.total).toBe(
result.tokenUsage!.prompt! + result.tokenUsage!.completion!,
);
});
it('should handle object output from transformResponse', async () => {
const provider = new HttpProvider('http://test.com', {
config: {
method: 'POST',
body: { prompt: '{{prompt}}' },
tokenEstimation: {
enabled: true,
},
transformResponse: 'json.message',
},
});
const mockResponse = {
data: JSON.stringify({ message: 'Hello world' }),
status: 200,
statusText: 'OK',
cached: false,
};
vi.mocked(fetchWithCache).mockResolvedValueOnce(mockResponse);
const result = await provider.callApi('Test prompt');
expect(result.tokenUsage).toBeDefined();
// Should use raw text when output is not a string
expect(result.tokenUsage!.completion).toBeGreaterThan(0);
});
it('should fall back to raw text when transformResponse returns an object', async () => {
const provider = new HttpProvider('http://test.com', {
config: {
method: 'POST',
body: { prompt: '{{prompt}}' },
tokenEstimation: {
enabled: true,
},
transformResponse: 'json', // returns the whole object, not a string
},
});
const mockResponse = {
data: JSON.stringify({ message: 'Hello world' }),
status: 200,
statusText: 'OK',
cached: false,
};
vi.mocked(fetchWithCache).mockResolvedValueOnce(mockResponse);
const result = await provider.callApi('Test prompt');
expect(result.tokenUsage).toBeDefined();
// Should use raw text when output is not a string
expect(result.tokenUsage!.completion).toBeGreaterThan(0);
});
});
describe('createSessionParser', () => {
it('should return empty string when no parser is provided', async () => {
const parser = await createSessionParser(undefined);
const result = parser({ headers: {}, body: {} });
expect(result).toBe('');
});
it('should handle function parser', async () => {
const functionParser = ({ headers }: { headers: Record<string, string> }) =>
headers['session-id'];
const parser = await createSessionParser(functionParser);
const result = parser({ headers: { 'session-id': 'test-session' } });
expect(result).toBe('test-session');
});
it('should handle header path expression', async () => {
const parser = await createSessionParser('data.headers["x-session-id"]');
const result = parser({
headers: { 'x-session-id': 'test-session' },
body: {},
});
expect(result).toBe('test-session');
});
it('should handle body path expression', async () => {
const parser = await createSessionParser('data.body.session.id');
const result = parser({
headers: {},
body: { session: { id: 'test-session' } },
});
expect(result).toBe('test-session');
});
it('should handle file:// parser', async () => {
const mockParser = vi.fn(({ headers }) => headers['session-id']);
vi.mocked(importModule).mockResolvedValueOnce(mockParser);
const parser = await createSessionParser('file://session-parser.js');
const result = parser({ headers: { 'session-id': 'test-session' } });
expect(result).toBe('test-session');
expect(importModule).toHaveBeenCalledWith(
path.resolve('/mock/base/path', 'session-parser.js'),
undefined,
);
});
it('should handle file:// parser with specific function', async () => {
const mockParser = vi.fn(({ body }) => body.sessionId);
vi.mocked(importModule).mockResolvedValueOnce(mockParser);
const parser = await createSessionParser('file://session-parser.js:parseSession');
const result = parser({ headers: {}, body: { sessionId: 'test-session' } });
expect(result).toBe('test-session');
expect(importModule).toHaveBeenCalledWith(
path.resolve('/mock/base/path', 'session-parser.js'),
'parseSession',
);
});
it('should throw error for malformed file:// parser', async () => {
vi.mocked(importModule).mockResolvedValueOnce({});
await expect(createSessionParser('file://invalid-parser.js')).rejects.toThrow(
/Response transform malformed/,
);
});
it('should handle complex body path expression', async () => {
const parser = await createSessionParser('data.body.data.attributes.session.id');
const result = parser({
headers: {},
body: {
data: {
attributes: {
session: {
id: 'test-session',
},
},
},
},
});
expect(result).toBe('test-session');
});
});
describe('Token Estimation', () => {
describe('estimateTokenCount', () => {
it('should count tokens using word-based method', () => {
const text = 'Hello world this is a test';
const result = estimateTokenCount(text, 1.3);
expect(result).toBe(Math.ceil(6 * 1.3)); // 6 words * 1.3 = 7.8, ceil = 8
});
it('should handle empty text', () => {
expect(estimateTokenCount('', 1.3)).toBe(0);
expect(estimateTokenCount(null as any, 1.3)).toBe(0);
expect(estimateTokenCount(undefined as any, 1.3)).toBe(0);
});
it('should filter out empty words', () => {
const text = 'hello world test'; // Multiple spaces
const result = estimateTokenCount(text, 1.0);
expect(result).toBe(3); // Should count 3 words, not split on every space
});
it('should use default multiplier when not provided', () => {
const text = 'hello world';
const result = estimateTokenCount(text);
expect(result).toBe(Math.ceil(2 * 1.3)); // Default multiplier is 1.3
});
});
});
describe('HttpProvider - Abort Signal Handling', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('should pass abortSignal to fetchWithCache', async () => {
const provider = new HttpProvider('http://example.com/api', {
config: {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: { key: '{{ prompt }}' },
},
});
const abortController = new AbortController();
const mockResponse = {
data: JSON.stringify({ result: 'response text' }),
status: 200,
statusText: 'OK',
cached: false,
};
vi.mocked(fetchWithCache).mockResolvedValueOnce(mockResponse);
await provider.callApi('test prompt', undefined, { abortSignal: abortController.signal });
expect(fetchWithCache).toHaveBeenCalledWith(
expect.any(String),
expect.objectContaining({ signal: abortController.signal }),
expect.any(Number),
expect.any(String),
undefined,
undefined,
);
});
it('should pass abortSignal to fetchWithCache in raw request mode', async () => {
const rawRequest = dedent`
POST /api HTTP/1.1
Host: example.com
Content-Type: application/json
{"key": "{{ prompt }}"}
`;
const provider = new HttpProvider('http://example.com', {
config: {
request: rawRequest,
},
});
const abortController = new AbortController();
const mockResponse = {
data: JSON.stringify({ result: 'response text' }),
status: 200,
statusText: 'OK',
cached: false,
};
vi.mocked(fetchWithCache).mockResolvedValueOnce(mockResponse);
await provider.callApi('test prompt', undefined, { abortSignal: abortController.signal });
expect(fetchWithCache).toHaveBeenCalledWith(
expect.any(String),
expect.objectContaining({ signal: abortController.signal }),
expect.any(Number),
expect.any(String),
undefined,
undefined,
);
});
it('should work without abortSignal (backwards compatibility)', async () => {
const provider = new HttpProvider('http://example.com/api', {
config: {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: { key: '{{ prompt }}' },
},
});
const mockResponse = {
data: JSON.stringify({ result: 'response text' }),
status: 200,
statusText: 'OK',
cached: false,
};
vi.mocked(fetchWithCache).mockResolvedValueOnce(mockResponse);
// Call without options parameter
await provider.callApi('test prompt');
expect(fetchWithCache).toHaveBeenCalledWith(
expect.any(String),
expect.not.objectContaining({ signal: expect.anything() }),
expect.any(Number),
expect.any(String),
undefined,
undefined,
);
});
});