chore: import upstream snapshot with attribution
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
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
This commit is contained in:
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,193 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { AzureChatCompletionProvider } from '../../../src/providers/azure/chat';
|
||||
|
||||
const mcpMocks = vi.hoisted(() => {
|
||||
const mockInitialize = vi.fn().mockResolvedValue(undefined);
|
||||
const mockCleanup = vi.fn().mockResolvedValue(undefined);
|
||||
const mockGetAllTools = vi.fn().mockReturnValue([
|
||||
{
|
||||
name: 'list_resources',
|
||||
description: 'List available token system resources',
|
||||
inputSchema: { type: 'object' },
|
||||
},
|
||||
]);
|
||||
const mockCallTool = vi.fn().mockResolvedValue({
|
||||
content: 'Available resources: [button-tokens.json, color-tokens.json, spacing-tokens.json]',
|
||||
});
|
||||
|
||||
class MockMCPClient {
|
||||
initialize = mockInitialize;
|
||||
cleanup = mockCleanup;
|
||||
getAllTools = mockGetAllTools;
|
||||
callTool = mockCallTool;
|
||||
}
|
||||
|
||||
return {
|
||||
MockMCPClient,
|
||||
mockInitialize,
|
||||
mockCleanup,
|
||||
mockGetAllTools,
|
||||
mockCallTool,
|
||||
};
|
||||
});
|
||||
|
||||
// Mock external dependencies
|
||||
vi.mock('../../../src/cache');
|
||||
vi.mock('../../../src/logger');
|
||||
vi.mock('../../../src/providers/mcp/client', async (importOriginal) => {
|
||||
return {
|
||||
...(await importOriginal()),
|
||||
MCPClient: mcpMocks.MockMCPClient,
|
||||
};
|
||||
});
|
||||
|
||||
describe('AzureChatCompletionProvider MCP Integration', () => {
|
||||
let provider: AzureChatCompletionProvider;
|
||||
|
||||
beforeEach(() => {
|
||||
// Reset mocks
|
||||
mcpMocks.mockInitialize.mockReset().mockResolvedValue(undefined);
|
||||
mcpMocks.mockCleanup.mockReset().mockResolvedValue(undefined);
|
||||
mcpMocks.mockGetAllTools.mockReset().mockReturnValue([
|
||||
{
|
||||
name: 'list_resources',
|
||||
description: 'List available token system resources',
|
||||
inputSchema: { type: 'object' },
|
||||
},
|
||||
]);
|
||||
mcpMocks.mockCallTool.mockReset().mockResolvedValue({
|
||||
content: 'Available resources: [button-tokens.json, color-tokens.json, spacing-tokens.json]',
|
||||
});
|
||||
|
||||
// Create provider with MCP enabled
|
||||
provider = new AzureChatCompletionProvider('test-deployment', {
|
||||
config: {
|
||||
apiKey: 'test-key',
|
||||
apiHost: 'https://test.openai.azure.com',
|
||||
mcp: {
|
||||
enabled: true,
|
||||
server: {
|
||||
command: 'npx',
|
||||
args: ['@fluentui-contrib/token-analyzer-mcp'],
|
||||
name: 'test-token-mcp',
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await provider.cleanup();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('should integrate MCP tools with FunctionCallbackHandler', async () => {
|
||||
// Wait for MCP initialization
|
||||
await (provider as any).initializationPromise;
|
||||
|
||||
// Verify MCP client was initialized
|
||||
expect(mcpMocks.mockInitialize).toHaveBeenCalled();
|
||||
|
||||
// Test that the function callback handler now has the MCP client
|
||||
const handler = (provider as any).functionCallbackHandler;
|
||||
expect(handler).toBeDefined();
|
||||
expect((handler as any).mcpClient).toBeDefined();
|
||||
});
|
||||
|
||||
it('should execute MCP tool through FunctionCallbackHandler', async () => {
|
||||
// Wait for MCP initialization
|
||||
await (provider as any).initializationPromise;
|
||||
|
||||
const handler = (provider as any).functionCallbackHandler;
|
||||
|
||||
// Simulate a tool call that matches an MCP tool
|
||||
const toolCall = {
|
||||
name: 'list_resources',
|
||||
arguments: '{}',
|
||||
};
|
||||
|
||||
const result = await handler.processCall(toolCall, {});
|
||||
|
||||
// Verify MCP tool was called
|
||||
expect(mcpMocks.mockCallTool).toHaveBeenCalledWith('list_resources', {});
|
||||
|
||||
// Verify result format matches expected pattern (not [object Object])
|
||||
expect(result).toEqual({
|
||||
output:
|
||||
'MCP Tool Result (list_resources): Available resources: [button-tokens.json, color-tokens.json, spacing-tokens.json]',
|
||||
isError: false,
|
||||
});
|
||||
|
||||
// Ensure it's not the problematic [object Object] output
|
||||
expect(result.output).not.toContain('[object Object]');
|
||||
});
|
||||
|
||||
it('should handle MCP tool errors gracefully', async () => {
|
||||
// Wait for MCP initialization
|
||||
await (provider as any).initializationPromise;
|
||||
|
||||
// Configure mock to return an error
|
||||
mcpMocks.mockCallTool.mockResolvedValue({
|
||||
content: '',
|
||||
error: 'MCP server connection failed',
|
||||
});
|
||||
|
||||
const handler = (provider as any).functionCallbackHandler;
|
||||
const toolCall = {
|
||||
name: 'list_resources',
|
||||
arguments: '{}',
|
||||
};
|
||||
|
||||
const result = await handler.processCall(toolCall, {});
|
||||
|
||||
expect(result).toEqual({
|
||||
output: 'MCP Tool Error (list_resources): MCP server connection failed',
|
||||
isError: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('should work without MCP enabled (backwards compatibility)', () => {
|
||||
// Create provider without MCP
|
||||
const providerWithoutMCP = new AzureChatCompletionProvider('test-deployment', {
|
||||
config: {
|
||||
apiKey: 'test-key',
|
||||
apiHost: 'https://test.openai.azure.com',
|
||||
// No MCP config
|
||||
},
|
||||
});
|
||||
|
||||
// Should not have MCP client
|
||||
expect((providerWithoutMCP as any).mcpClient).toBeNull();
|
||||
|
||||
// FunctionCallbackHandler should work without MCP client
|
||||
const handler = (providerWithoutMCP as any).functionCallbackHandler;
|
||||
expect(handler).toBeDefined();
|
||||
expect((handler as any).mcpClient).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should prioritize MCP tools over function callbacks', async () => {
|
||||
// Wait for MCP initialization
|
||||
await (provider as any).initializationPromise;
|
||||
|
||||
const handler = (provider as any).functionCallbackHandler;
|
||||
|
||||
// Create a function callback with the same name as an MCP tool
|
||||
const functionCallbacks = {
|
||||
list_resources: vi.fn().mockResolvedValue('Function callback result'),
|
||||
};
|
||||
|
||||
const toolCall = {
|
||||
name: 'list_resources',
|
||||
arguments: '{}',
|
||||
};
|
||||
|
||||
const result = await handler.processCall(toolCall, functionCallbacks);
|
||||
|
||||
// Should call MCP tool, not function callback
|
||||
expect(mcpMocks.mockCallTool).toHaveBeenCalledWith('list_resources', {});
|
||||
expect(functionCallbacks.list_resources).not.toHaveBeenCalled();
|
||||
|
||||
// Result should be from MCP tool
|
||||
expect(result.output).toContain('MCP Tool Result');
|
||||
});
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,287 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { fetchWithCache } from '../../../src/cache';
|
||||
import { AzureCompletionProvider } from '../../../src/providers/azure/completion';
|
||||
import { mockProcessEnv } from '../../util/utils';
|
||||
|
||||
vi.mock('../../../src/cache', async (importOriginal) => {
|
||||
return {
|
||||
...(await importOriginal()),
|
||||
fetchWithCache: vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
const setAuthHeaders = (
|
||||
provider: AzureCompletionProvider,
|
||||
headers: Record<string, string> = { 'api-key': 'test-key' },
|
||||
) => {
|
||||
(provider as any).authHeaders = headers;
|
||||
(provider as any).initialized = true;
|
||||
};
|
||||
|
||||
describe('AzureCompletionProvider', () => {
|
||||
beforeEach(() => {
|
||||
vi.spyOn(AzureCompletionProvider.prototype as any, 'ensureInitialized').mockResolvedValue(
|
||||
undefined,
|
||||
);
|
||||
vi.spyOn(AzureCompletionProvider.prototype as any, 'getAuthHeaders').mockResolvedValue({
|
||||
'api-key': 'test-key',
|
||||
});
|
||||
mockProcessEnv({ OPENAI_STOP: undefined });
|
||||
mockProcessEnv({ AZURE_API_HOST: 'test.azure.com' });
|
||||
mockProcessEnv({ AZURE_API_KEY: 'test-key' });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
mockProcessEnv({ AZURE_API_HOST: undefined });
|
||||
mockProcessEnv({ AZURE_API_KEY: undefined });
|
||||
vi.restoreAllMocks();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('should handle basic completion with caching', async () => {
|
||||
vi.mocked(fetchWithCache).mockResolvedValueOnce({
|
||||
data: {
|
||||
choices: [{ text: 'hello' }],
|
||||
usage: { total_tokens: 10, prompt_tokens: 5, completion_tokens: 5 },
|
||||
},
|
||||
cached: false,
|
||||
} as any);
|
||||
|
||||
vi.mocked(fetchWithCache).mockResolvedValueOnce({
|
||||
data: {
|
||||
choices: [{ text: 'hello' }],
|
||||
usage: { total_tokens: 10 },
|
||||
},
|
||||
cached: true,
|
||||
} as any);
|
||||
|
||||
const provider = new AzureCompletionProvider('test', {
|
||||
config: { apiHost: 'test.azure.com' },
|
||||
});
|
||||
setAuthHeaders(provider);
|
||||
|
||||
const result1 = await provider.callApi('test prompt');
|
||||
const result2 = await provider.callApi('test prompt');
|
||||
|
||||
expect(result1.output).toBe('hello');
|
||||
expect(result2.output).toBe('hello');
|
||||
expect(result1.tokenUsage).toEqual({ total: 10, prompt: 5, completion: 5 });
|
||||
expect(result2.tokenUsage).toEqual({ cached: 10, total: 10 });
|
||||
});
|
||||
|
||||
it('should pass custom headers from config to fetchWithCache', async () => {
|
||||
vi.mocked(fetchWithCache).mockResolvedValueOnce({
|
||||
data: {
|
||||
choices: [{ text: 'hello' }],
|
||||
usage: { total_tokens: 10, prompt_tokens: 5, completion_tokens: 5 },
|
||||
},
|
||||
cached: false,
|
||||
} as any);
|
||||
|
||||
const customHeaders = {
|
||||
'X-Test-Header': 'test-value',
|
||||
'Another-Header': 'another-value',
|
||||
};
|
||||
|
||||
const provider = new AzureCompletionProvider('test-deployment', {
|
||||
config: {
|
||||
apiHost: 'test.azure.com',
|
||||
apiKey: 'test-key',
|
||||
headers: customHeaders,
|
||||
},
|
||||
});
|
||||
|
||||
setAuthHeaders(provider);
|
||||
await provider.callApi('test prompt');
|
||||
|
||||
expect(fetchWithCache).toHaveBeenCalledWith(
|
||||
expect.any(String),
|
||||
expect.objectContaining({
|
||||
headers: expect.objectContaining({
|
||||
'Content-Type': 'application/json',
|
||||
'api-key': 'test-key',
|
||||
'X-Test-Header': 'test-value',
|
||||
'Another-Header': 'another-value',
|
||||
}),
|
||||
}),
|
||||
expect.any(Number),
|
||||
'json',
|
||||
undefined,
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle content filter response', async () => {
|
||||
vi.mocked(fetchWithCache).mockResolvedValueOnce({
|
||||
data: {
|
||||
choices: [{ text: null, finish_reason: 'content_filter' }],
|
||||
usage: { total_tokens: 10, prompt_tokens: 5, completion_tokens: 5 },
|
||||
},
|
||||
cached: false,
|
||||
} as any);
|
||||
|
||||
const provider = new AzureCompletionProvider('test', {
|
||||
config: { apiHost: 'test.azure.com' },
|
||||
});
|
||||
setAuthHeaders(provider);
|
||||
|
||||
const result = await provider.callApi('test prompt');
|
||||
expect(result.output).toBe(
|
||||
"The generated content was filtered due to triggering Azure OpenAI Service's content filtering system.",
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle API errors', async () => {
|
||||
vi.mocked(fetchWithCache).mockRejectedValueOnce(new Error('API Error'));
|
||||
|
||||
const provider = new AzureCompletionProvider('test', {
|
||||
config: { apiHost: 'test.azure.com' },
|
||||
});
|
||||
setAuthHeaders(provider);
|
||||
|
||||
const result = await provider.callApi('test prompt');
|
||||
expect(result.error).toBe('API call error: Error: API Error');
|
||||
});
|
||||
|
||||
it('should handle missing API host', async () => {
|
||||
vi.mocked(fetchWithCache).mockImplementationOnce(function () {
|
||||
throw new Error('Azure API host must be set.');
|
||||
});
|
||||
|
||||
const provider = new AzureCompletionProvider('test', { config: {} });
|
||||
setAuthHeaders(provider);
|
||||
|
||||
const result = await provider.callApi('test prompt');
|
||||
expect(result.error).toBe('API call error: Error: Azure API host must be set.');
|
||||
});
|
||||
|
||||
it('should handle empty response text', async () => {
|
||||
vi.mocked(fetchWithCache).mockResolvedValueOnce({
|
||||
data: {
|
||||
choices: [{ text: '', finish_reason: 'stop' }],
|
||||
usage: { total_tokens: 10, prompt_tokens: 5, completion_tokens: 5 },
|
||||
},
|
||||
cached: false,
|
||||
} as any);
|
||||
|
||||
const provider = new AzureCompletionProvider('test', {
|
||||
config: { apiHost: 'test.azure.com' },
|
||||
});
|
||||
setAuthHeaders(provider);
|
||||
|
||||
const result = await provider.callApi('test prompt');
|
||||
expect(result.output).toBe('');
|
||||
});
|
||||
|
||||
it('should handle invalid OPENAI_STOP env var', async () => {
|
||||
mockProcessEnv({ OPENAI_STOP: '{invalid json}' });
|
||||
|
||||
const provider = new AzureCompletionProvider('test', {
|
||||
config: { apiHost: 'test.azure.com' },
|
||||
});
|
||||
setAuthHeaders(provider);
|
||||
|
||||
await expect(provider.callApi('test')).rejects.toThrow(
|
||||
/OPENAI_STOP is not a valid JSON string/,
|
||||
);
|
||||
|
||||
mockProcessEnv({ OPENAI_STOP: undefined });
|
||||
});
|
||||
|
||||
it('should handle missing output and finish_reason not content_filter', async () => {
|
||||
vi.mocked(fetchWithCache).mockResolvedValueOnce({
|
||||
data: {
|
||||
choices: [{ text: null, finish_reason: 'stop' }],
|
||||
usage: { total_tokens: 7, prompt_tokens: 3, completion_tokens: 4 },
|
||||
},
|
||||
cached: false,
|
||||
} as any);
|
||||
|
||||
const provider = new AzureCompletionProvider('test', {
|
||||
config: { apiHost: 'test.azure.com' },
|
||||
});
|
||||
setAuthHeaders(provider);
|
||||
|
||||
const result = await provider.callApi('test prompt');
|
||||
expect(result.output).toBe('');
|
||||
expect(result.tokenUsage).toEqual({ total: 7, prompt: 3, completion: 4 });
|
||||
});
|
||||
|
||||
it('should handle exception in response parsing gracefully', async () => {
|
||||
vi.mocked(fetchWithCache).mockResolvedValueOnce({
|
||||
data: {},
|
||||
cached: false,
|
||||
} as any);
|
||||
|
||||
const provider = new AzureCompletionProvider('test', {
|
||||
config: { apiHost: 'test.azure.com' },
|
||||
});
|
||||
setAuthHeaders(provider);
|
||||
|
||||
const result = await provider.callApi('test prompt');
|
||||
expect(result.error).toMatch(/API response error:/);
|
||||
expect(result.tokenUsage).toEqual({
|
||||
total: undefined,
|
||||
prompt: undefined,
|
||||
completion: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it('should pass passthrough config fields in body', async () => {
|
||||
vi.mocked(fetchWithCache).mockResolvedValueOnce({
|
||||
data: {
|
||||
choices: [{ text: 'foo' }],
|
||||
usage: { total_tokens: 1, prompt_tokens: 1, completion_tokens: 0 },
|
||||
},
|
||||
cached: false,
|
||||
} as any);
|
||||
|
||||
const provider = new AzureCompletionProvider('test', {
|
||||
config: {
|
||||
apiHost: 'test.azure.com',
|
||||
passthrough: { logprobs: 3 },
|
||||
},
|
||||
});
|
||||
setAuthHeaders(provider);
|
||||
|
||||
await provider.callApi('test prompt');
|
||||
|
||||
const actualCall = vi.mocked(fetchWithCache).mock.calls[0];
|
||||
const body = JSON.parse(actualCall[1]?.body as string);
|
||||
expect(body.logprobs).toBe(3);
|
||||
});
|
||||
|
||||
it('should allow config.headers to override authHeaders', async () => {
|
||||
vi.mocked(fetchWithCache).mockResolvedValueOnce({
|
||||
data: {
|
||||
choices: [{ text: 'override' }],
|
||||
usage: { total_tokens: 2, prompt_tokens: 1, completion_tokens: 1 },
|
||||
},
|
||||
cached: false,
|
||||
} as any);
|
||||
|
||||
const provider = new AzureCompletionProvider('test-deployment', {
|
||||
config: {
|
||||
apiHost: 'test.azure.com',
|
||||
apiKey: 'test-key',
|
||||
headers: { 'api-key': 'override-key', Extra: 'foo' },
|
||||
},
|
||||
});
|
||||
|
||||
setAuthHeaders(provider);
|
||||
await provider.callApi('test prompt');
|
||||
|
||||
expect(fetchWithCache).toHaveBeenCalledWith(
|
||||
expect.any(String),
|
||||
expect.objectContaining({
|
||||
headers: expect.objectContaining({
|
||||
'Content-Type': 'application/json',
|
||||
'api-key': 'override-key',
|
||||
Extra: 'foo',
|
||||
}),
|
||||
}),
|
||||
expect.any(Number),
|
||||
'json',
|
||||
undefined,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,159 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { fetchWithCache } from '../../../src/cache';
|
||||
import { AzureEmbeddingProvider } from '../../../src/providers/azure/embedding';
|
||||
|
||||
vi.mock('../../../src/cache');
|
||||
|
||||
describe('AzureEmbeddingProvider', () => {
|
||||
let provider: AzureEmbeddingProvider;
|
||||
|
||||
beforeEach(() => {
|
||||
provider = new AzureEmbeddingProvider('test-deployment', {
|
||||
endpoint: 'https://test.openai.azure.com',
|
||||
apiKey: 'test-key',
|
||||
headers: {
|
||||
'Custom-Header': 'custom-value',
|
||||
},
|
||||
} as any);
|
||||
|
||||
(provider as any).getApiBaseUrl = () => 'https://test.openai.azure.com';
|
||||
(provider as any).authHeaders = {
|
||||
'api-key': 'test-key',
|
||||
};
|
||||
vi.spyOn(provider as any, 'ensureInitialized').mockImplementation(function () {
|
||||
return Promise.resolve();
|
||||
});
|
||||
|
||||
vi.mocked(fetchWithCache).mockReset();
|
||||
});
|
||||
|
||||
it('should handle cached response', async () => {
|
||||
const mockResponse = {
|
||||
data: {
|
||||
data: [
|
||||
{
|
||||
embedding: [0.1, 0.2, 0.3],
|
||||
},
|
||||
],
|
||||
usage: {
|
||||
total_tokens: 10,
|
||||
},
|
||||
},
|
||||
cached: true,
|
||||
};
|
||||
|
||||
vi.mocked(fetchWithCache).mockResolvedValueOnce(mockResponse as any);
|
||||
|
||||
const result = await provider.callEmbeddingApi('test text');
|
||||
|
||||
expect(result).toEqual({
|
||||
embedding: [0.1, 0.2, 0.3],
|
||||
cached: true,
|
||||
tokenUsage: {
|
||||
cached: 10,
|
||||
total: 10,
|
||||
numRequests: 1,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle API call errors', async () => {
|
||||
vi.mocked(fetchWithCache).mockRejectedValueOnce(new Error('API error'));
|
||||
|
||||
const result = await provider.callEmbeddingApi('test text');
|
||||
|
||||
expect(result).toEqual({
|
||||
error: 'API call error: Error: API error',
|
||||
tokenUsage: {
|
||||
total: 0,
|
||||
prompt: 0,
|
||||
completion: 0,
|
||||
numRequests: 1,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle missing embedding in response', async () => {
|
||||
const mockResponse = {
|
||||
data: {
|
||||
data: [{}],
|
||||
usage: {
|
||||
total_tokens: 10,
|
||||
prompt_tokens: 5,
|
||||
completion_tokens: 5,
|
||||
},
|
||||
},
|
||||
cached: false,
|
||||
};
|
||||
|
||||
vi.mocked(fetchWithCache).mockResolvedValueOnce(mockResponse as any);
|
||||
|
||||
const result = await provider.callEmbeddingApi('test text');
|
||||
|
||||
expect(result).toEqual({
|
||||
error: expect.stringContaining('No embedding returned'),
|
||||
tokenUsage: {
|
||||
total: 10,
|
||||
prompt: 5,
|
||||
completion: 5,
|
||||
numRequests: 1,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle missing API host', async () => {
|
||||
(provider as any).getApiBaseUrl = () => undefined;
|
||||
|
||||
await expect(provider.callEmbeddingApi('test text')).rejects.toThrow(
|
||||
'Azure API host must be set.',
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle API response error with missing usage fields', async () => {
|
||||
const mockResponse = {
|
||||
data: {
|
||||
data: [{}],
|
||||
// usage is missing
|
||||
},
|
||||
cached: false,
|
||||
};
|
||||
|
||||
vi.mocked(fetchWithCache).mockResolvedValueOnce(mockResponse as any);
|
||||
|
||||
const result = await provider.callEmbeddingApi('test text');
|
||||
|
||||
expect(result).toEqual({
|
||||
error: expect.stringContaining('No embedding returned'),
|
||||
tokenUsage: {
|
||||
total: undefined,
|
||||
prompt: undefined,
|
||||
completion: undefined,
|
||||
numRequests: 1,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('handles a cached response with missing usage fields gracefully (no throw)', async () => {
|
||||
const mockResponse = {
|
||||
data: {
|
||||
data: [{}],
|
||||
// usage is missing
|
||||
},
|
||||
cached: true,
|
||||
};
|
||||
|
||||
vi.mocked(fetchWithCache).mockResolvedValueOnce(mockResponse as any);
|
||||
|
||||
// Previously the cached error path dereferenced data.usage.total_tokens and threw a
|
||||
// TypeError; it must now degrade to a clean error object.
|
||||
const result = await provider.callEmbeddingApi('test text');
|
||||
expect(result).toEqual({
|
||||
error: expect.stringContaining('No embedding returned'),
|
||||
tokenUsage: {
|
||||
cached: undefined,
|
||||
total: undefined,
|
||||
numRequests: 1,
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,98 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
formatContentFilterResponse,
|
||||
isContentFilterError,
|
||||
isRateLimitError,
|
||||
isServiceError,
|
||||
} from '../../../src/providers/azure/errors';
|
||||
|
||||
describe('isContentFilterError', () => {
|
||||
it.each([
|
||||
'content_filter triggered',
|
||||
'content filter violation',
|
||||
'Content filter blocked this',
|
||||
'filtered due to policy',
|
||||
'content filtering system',
|
||||
'inappropriate content detected',
|
||||
'safety guidelines violation',
|
||||
'guardrail triggered',
|
||||
])('matches %j', (msg) => {
|
||||
expect(isContentFilterError(msg)).toBe(true);
|
||||
});
|
||||
|
||||
it('returns false for unrelated errors', () => {
|
||||
expect(isContentFilterError('some other error')).toBe(false);
|
||||
expect(isContentFilterError('')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isRateLimitError', () => {
|
||||
it.each([
|
||||
'rate limit exceeded',
|
||||
'Rate limit reached',
|
||||
'HTTP 429 Too Many Requests',
|
||||
'Quota exceeded for daily tokens',
|
||||
])('matches %j', (msg) => {
|
||||
expect(isRateLimitError(msg)).toBe(true);
|
||||
});
|
||||
|
||||
it('returns false for unrelated errors', () => {
|
||||
expect(isRateLimitError('some other error')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isServiceError', () => {
|
||||
it.each([
|
||||
'Service unavailable',
|
||||
'Bad gateway',
|
||||
'Gateway timeout',
|
||||
'Server is busy',
|
||||
'Sorry, something went wrong',
|
||||
])('matches %j', (msg) => {
|
||||
expect(isServiceError(msg)).toBe(true);
|
||||
});
|
||||
|
||||
it('returns false for unrelated errors', () => {
|
||||
expect(isServiceError('some other error')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('formatContentFilterResponse', () => {
|
||||
it('flags input when message names the prompt/input', () => {
|
||||
const result = formatContentFilterResponse(
|
||||
'Content filter triggered: The input contained inappropriate content',
|
||||
);
|
||||
expect(result.guardrails).toEqual({
|
||||
flagged: true,
|
||||
flaggedInput: true,
|
||||
flaggedOutput: false,
|
||||
});
|
||||
expect(result.output).toContain('Azure OpenAI');
|
||||
});
|
||||
|
||||
it('flags output when message names the response', () => {
|
||||
const result = formatContentFilterResponse('content filter blocked response output');
|
||||
expect(result.guardrails).toEqual({
|
||||
flagged: true,
|
||||
flaggedInput: false,
|
||||
flaggedOutput: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('defaults to flagged output when neither side is named', () => {
|
||||
const result = formatContentFilterResponse('content_filter violation detected');
|
||||
expect(result.guardrails).toEqual({
|
||||
flagged: true,
|
||||
flaggedInput: false,
|
||||
flaggedOutput: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('treats input/output as mutually exclusive when both terms appear', () => {
|
||||
// Mirrors the Azure content-filter response shape: a single trip is one
|
||||
// side or the other, never both. Input wins when the message mentions it.
|
||||
const result = formatContentFilterResponse('input prompt produced filtered response output');
|
||||
expect(result.guardrails?.flaggedInput).toBe(true);
|
||||
expect(result.guardrails?.flaggedOutput).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,842 @@
|
||||
import { SpanStatusCode, trace } from '@opentelemetry/api';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { getCache, isCacheEnabled } from '../../../src/cache';
|
||||
import logger from '../../../src/logger';
|
||||
import { AzureFoundryAgentProvider } from '../../../src/providers/azure/foundry-agent';
|
||||
import { mockProcessEnv } from '../../util/utils';
|
||||
|
||||
vi.mock('../../../src/cache', async (importOriginal) => {
|
||||
return {
|
||||
...(await importOriginal()),
|
||||
getCache: vi.fn(),
|
||||
isCacheEnabled: vi.fn().mockReturnValue(false),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock('../../../src/logger', () => ({
|
||||
__esModule: true,
|
||||
default: {
|
||||
debug: vi.fn(),
|
||||
error: vi.fn(),
|
||||
info: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
const projectUrl = 'https://test.services.ai.azure.com/api/projects/test-project';
|
||||
const mockAgent = {
|
||||
id: 'agent_123',
|
||||
name: 'weather-agent',
|
||||
object: 'agent',
|
||||
versions: {
|
||||
latest: {},
|
||||
},
|
||||
} as any;
|
||||
|
||||
function createAsyncIterable<T>(items: T[]): AsyncIterable<T> {
|
||||
return {
|
||||
async *[Symbol.asyncIterator]() {
|
||||
for (const item of items) {
|
||||
yield item;
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function createMessageResponse(text: string) {
|
||||
return {
|
||||
id: 'resp_123',
|
||||
model: 'gpt-4.1',
|
||||
error: null,
|
||||
output: [
|
||||
{
|
||||
type: 'message',
|
||||
role: 'assistant',
|
||||
content: [{ type: 'output_text', text }],
|
||||
},
|
||||
],
|
||||
usage: {
|
||||
input_tokens: 10,
|
||||
output_tokens: 5,
|
||||
total_tokens: 15,
|
||||
},
|
||||
} as any;
|
||||
}
|
||||
|
||||
function createFunctionCallResponse() {
|
||||
return {
|
||||
id: 'resp_tool',
|
||||
model: 'gpt-4.1',
|
||||
error: null,
|
||||
conversation: { id: 'conv_123' },
|
||||
output: [
|
||||
{
|
||||
type: 'function_call',
|
||||
id: 'fc_123',
|
||||
call_id: 'call_123',
|
||||
name: 'get_weather',
|
||||
arguments: '{"location":"Paris"}',
|
||||
status: 'completed',
|
||||
},
|
||||
],
|
||||
usage: {
|
||||
input_tokens: 20,
|
||||
output_tokens: 10,
|
||||
total_tokens: 30,
|
||||
},
|
||||
} as any;
|
||||
}
|
||||
|
||||
interface RecordedSpan {
|
||||
name: string;
|
||||
attributes: Record<string, unknown>;
|
||||
status?: { code: number; message?: string };
|
||||
}
|
||||
|
||||
function installSpanRecorder(): RecordedSpan[] {
|
||||
const spans: RecordedSpan[] = [];
|
||||
const createSpan = (name: string, attributes: Record<string, unknown> = {}) => {
|
||||
const entry: RecordedSpan = { name, attributes: { ...attributes } };
|
||||
spans.push(entry);
|
||||
return {
|
||||
end: vi.fn(),
|
||||
recordException: vi.fn(),
|
||||
setAttribute: vi.fn((key: string, value: unknown) => {
|
||||
entry.attributes[key] = value;
|
||||
}),
|
||||
setStatus: vi.fn((status: { code: number; message?: string }) => {
|
||||
entry.status = status;
|
||||
}),
|
||||
};
|
||||
};
|
||||
|
||||
vi.spyOn(trace, 'getTracer').mockReturnValue({
|
||||
startSpan: (name: string, options?: { attributes?: Record<string, unknown> }) =>
|
||||
createSpan(name, options?.attributes),
|
||||
startActiveSpan: (
|
||||
name: string,
|
||||
options: { attributes?: Record<string, unknown> },
|
||||
_parentContext: unknown,
|
||||
callback: (span: unknown) => unknown,
|
||||
) => callback(createSpan(name, options?.attributes)),
|
||||
} as any);
|
||||
|
||||
return spans;
|
||||
}
|
||||
|
||||
describe('AzureFoundryAgentProvider', () => {
|
||||
let mockResponsesCreate: ReturnType<typeof vi.fn>;
|
||||
let mockGetAgent: ReturnType<typeof vi.fn>;
|
||||
let mockListAgents: ReturnType<typeof vi.fn>;
|
||||
let mockClient: any;
|
||||
let restoreEnv: () => void;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
restoreEnv = mockProcessEnv({ AZURE_AI_PROJECT_URL: undefined });
|
||||
|
||||
mockResponsesCreate = vi.fn();
|
||||
mockGetAgent = vi.fn();
|
||||
mockListAgents = vi.fn().mockReturnValue(createAsyncIterable([]));
|
||||
mockClient = {
|
||||
agents: {
|
||||
get: mockGetAgent,
|
||||
list: mockListAgents,
|
||||
},
|
||||
getOpenAIClient: vi.fn(() => ({
|
||||
responses: {
|
||||
create: mockResponsesCreate,
|
||||
},
|
||||
})),
|
||||
};
|
||||
|
||||
vi.spyOn(AzureFoundryAgentProvider.prototype as any, 'initializeClient').mockResolvedValue(
|
||||
mockClient,
|
||||
);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
restoreEnv();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe('instantiation', () => {
|
||||
it('should create provider with minimal config', () => {
|
||||
const provider = new AzureFoundryAgentProvider('weather-agent', {
|
||||
config: {
|
||||
projectUrl,
|
||||
},
|
||||
});
|
||||
|
||||
expect(provider).toBeDefined();
|
||||
expect(provider.deploymentName).toBe('weather-agent');
|
||||
});
|
||||
|
||||
it('should throw when projectUrl is missing', () => {
|
||||
expect(() => new AzureFoundryAgentProvider('weather-agent', { config: {} })).toThrow(
|
||||
'Azure AI Project URL must be provided',
|
||||
);
|
||||
});
|
||||
|
||||
it('should accept projectUrl from AZURE_AI_PROJECT_URL', () => {
|
||||
const restoreProjectUrl = mockProcessEnv({ AZURE_AI_PROJECT_URL: projectUrl });
|
||||
try {
|
||||
const provider = new AzureFoundryAgentProvider('weather-agent', {
|
||||
config: {},
|
||||
});
|
||||
|
||||
expect(provider).toBeDefined();
|
||||
} finally {
|
||||
restoreProjectUrl();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('v2 responses runtime', () => {
|
||||
it('should call responses.create for a resolved agent', async () => {
|
||||
mockGetAgent.mockResolvedValue(mockAgent);
|
||||
mockResponsesCreate.mockResolvedValue(createMessageResponse('Test response'));
|
||||
|
||||
const provider = new AzureFoundryAgentProvider('weather-agent', {
|
||||
config: {
|
||||
projectUrl,
|
||||
temperature: 0.2,
|
||||
top_p: 0.8,
|
||||
},
|
||||
});
|
||||
|
||||
const result = await provider.callApi('test prompt');
|
||||
|
||||
expect(mockGetAgent).toHaveBeenCalledWith('weather-agent');
|
||||
expect(mockClient.getOpenAIClient).toHaveBeenCalledTimes(1);
|
||||
expect(mockResponsesCreate).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
input: [
|
||||
{
|
||||
type: 'message',
|
||||
role: 'user',
|
||||
content: 'test prompt',
|
||||
},
|
||||
],
|
||||
temperature: 0.2,
|
||||
top_p: 0.8,
|
||||
}),
|
||||
{
|
||||
body: {
|
||||
agent_reference: {
|
||||
name: 'weather-agent',
|
||||
type: 'agent_reference',
|
||||
},
|
||||
},
|
||||
},
|
||||
);
|
||||
// Guard against regressing to the deprecated `agent` key, which the
|
||||
// Foundry Responses API now rejects with a 400.
|
||||
const responseOptions = mockResponsesCreate.mock.calls[0][1];
|
||||
expect(responseOptions.body).not.toHaveProperty('agent');
|
||||
expect(result.output).toBe('Test response');
|
||||
});
|
||||
|
||||
it('reports non-zero cost from the agent usage object (regression)', async () => {
|
||||
// Regression for the costCalculator that passed `usage` into calculateAzureCost's ignored
|
||||
// config slot, so Foundry Agent evals always reported cost 0.
|
||||
mockGetAgent.mockResolvedValue(mockAgent);
|
||||
mockResponsesCreate.mockResolvedValue(createMessageResponse('priced'));
|
||||
|
||||
const provider = new AzureFoundryAgentProvider('weather-agent', {
|
||||
config: { projectUrl, modelName: 'gpt-4.1' } as any,
|
||||
});
|
||||
|
||||
const result = await provider.callApi('test prompt');
|
||||
|
||||
// gpt-4.1 is priced in AZURE_MODELS and input_tokens/output_tokens now flow through.
|
||||
expect(result.cost).toBeGreaterThan(0);
|
||||
expect(result.tokenUsage).toMatchObject({ prompt: 10, completion: 5 });
|
||||
});
|
||||
|
||||
it('should fall back to listing agents for legacy ids', async () => {
|
||||
mockGetAgent.mockRejectedValue(new Error('not found'));
|
||||
mockListAgents.mockReturnValue(
|
||||
createAsyncIterable([
|
||||
{ ...mockAgent, id: 'asst_legacy' },
|
||||
{ ...mockAgent, id: 'other' },
|
||||
]),
|
||||
);
|
||||
mockResponsesCreate.mockResolvedValue(createMessageResponse('Listed response'));
|
||||
|
||||
const provider = new AzureFoundryAgentProvider('asst_legacy', {
|
||||
config: {
|
||||
projectUrl,
|
||||
},
|
||||
});
|
||||
|
||||
const result = await provider.callApi('test prompt');
|
||||
|
||||
expect(mockGetAgent).toHaveBeenCalledWith('asst_legacy');
|
||||
expect(mockListAgents).toHaveBeenCalledTimes(1);
|
||||
expect(mockResponsesCreate).toHaveBeenCalledWith(expect.any(Object), {
|
||||
body: {
|
||||
agent_reference: {
|
||||
name: 'weather-agent',
|
||||
type: 'agent_reference',
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(result.output).toBe('Listed response');
|
||||
});
|
||||
|
||||
it('should map max_completion_tokens and response_format to Responses API fields', async () => {
|
||||
mockGetAgent.mockResolvedValue(mockAgent);
|
||||
mockResponsesCreate.mockResolvedValue(createMessageResponse('{"ok":true}'));
|
||||
|
||||
const provider = new AzureFoundryAgentProvider('weather-agent', {
|
||||
config: {
|
||||
projectUrl,
|
||||
max_tokens: 150,
|
||||
max_completion_tokens: 200,
|
||||
response_format: {
|
||||
type: 'json_schema',
|
||||
json_schema: {
|
||||
name: 'answer',
|
||||
strict: false,
|
||||
schema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
ok: { type: 'boolean' },
|
||||
},
|
||||
additionalProperties: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const result = await provider.callApi('test prompt');
|
||||
|
||||
expect(mockResponsesCreate).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
max_output_tokens: 200,
|
||||
text: {
|
||||
format: {
|
||||
type: 'json_schema',
|
||||
name: 'answer',
|
||||
schema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
ok: { type: 'boolean' },
|
||||
},
|
||||
additionalProperties: false,
|
||||
},
|
||||
strict: false,
|
||||
},
|
||||
},
|
||||
}),
|
||||
expect.any(Object),
|
||||
);
|
||||
expect(result.output).toEqual({ ok: true });
|
||||
});
|
||||
|
||||
it('should execute prompt-level function callbacks and continue with function_call_output items', async () => {
|
||||
mockGetAgent.mockResolvedValue(mockAgent);
|
||||
mockResponsesCreate
|
||||
.mockResolvedValueOnce(createFunctionCallResponse())
|
||||
.mockResolvedValueOnce(createMessageResponse('Tool finished'));
|
||||
const callback = vi.fn().mockResolvedValue({ forecast: 'sunny' });
|
||||
|
||||
const provider = new AzureFoundryAgentProvider('weather-agent', {
|
||||
config: {
|
||||
projectUrl,
|
||||
},
|
||||
});
|
||||
|
||||
const result = await provider.callApi('test prompt', {
|
||||
prompt: {
|
||||
config: {
|
||||
functionToolCallbacks: {
|
||||
get_weather: callback,
|
||||
},
|
||||
},
|
||||
},
|
||||
} as any);
|
||||
|
||||
expect(callback).toHaveBeenCalledWith(
|
||||
'{"location":"Paris"}',
|
||||
expect.objectContaining({
|
||||
threadId: 'conv_123',
|
||||
runId: 'resp_tool',
|
||||
assistantId: 'agent_123',
|
||||
provider: 'azure-foundry',
|
||||
}),
|
||||
);
|
||||
expect(mockResponsesCreate).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
{
|
||||
input: [
|
||||
{
|
||||
type: 'function_call_output',
|
||||
call_id: 'call_123',
|
||||
output: '{"forecast":"sunny"}',
|
||||
},
|
||||
],
|
||||
previous_response_id: 'resp_tool',
|
||||
},
|
||||
{
|
||||
body: {
|
||||
agent_reference: {
|
||||
name: 'weather-agent',
|
||||
type: 'agent_reference',
|
||||
},
|
||||
},
|
||||
},
|
||||
);
|
||||
expect(result.output).toBe('Tool finished');
|
||||
});
|
||||
|
||||
it('should emit a gen_ai.turn span for each model request in a function loop', async () => {
|
||||
const spans = installSpanRecorder();
|
||||
mockGetAgent.mockResolvedValue(mockAgent);
|
||||
mockResponsesCreate
|
||||
.mockResolvedValueOnce(createFunctionCallResponse())
|
||||
.mockResolvedValueOnce(createMessageResponse('Tool finished'));
|
||||
|
||||
const provider = new AzureFoundryAgentProvider('weather-agent', {
|
||||
config: {
|
||||
projectUrl,
|
||||
functionToolCallbacks: {
|
||||
get_weather: vi.fn().mockResolvedValue({ forecast: 'sunny' }),
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await provider.callApi('test prompt');
|
||||
|
||||
const turnSpans = spans.filter((span) => span.name.startsWith('gen_ai.turn '));
|
||||
expect(turnSpans.map((span) => span.name)).toEqual(['gen_ai.turn 1', 'gen_ai.turn 2']);
|
||||
expect(turnSpans.map((span) => span.attributes['gen_ai.turn.index'])).toEqual([1, 2]);
|
||||
expect(turnSpans.every((span) => span.status?.code === SpanStatusCode.OK)).toBe(true);
|
||||
});
|
||||
|
||||
it('should mark a failed model request turn as errored', async () => {
|
||||
const spans = installSpanRecorder();
|
||||
mockGetAgent.mockResolvedValue(mockAgent);
|
||||
mockResponsesCreate.mockRejectedValue(new Error('model request failed'));
|
||||
|
||||
const provider = new AzureFoundryAgentProvider('weather-agent', {
|
||||
config: { projectUrl },
|
||||
});
|
||||
|
||||
const result = await provider.callApi('test prompt');
|
||||
|
||||
expect(result.error).toContain('model request failed');
|
||||
const turnSpan = spans.find((span) => span.name === 'gen_ai.turn 1');
|
||||
expect(turnSpan?.status).toEqual({
|
||||
code: SpanStatusCode.ERROR,
|
||||
message: 'model request failed',
|
||||
});
|
||||
});
|
||||
|
||||
it('wraps each call in a chat <model> span', async () => {
|
||||
const spans = installSpanRecorder();
|
||||
mockGetAgent.mockResolvedValue(mockAgent);
|
||||
mockResponsesCreate.mockResolvedValueOnce(createMessageResponse('Hello'));
|
||||
|
||||
const provider = new AzureFoundryAgentProvider('weather-agent', {
|
||||
config: { projectUrl },
|
||||
});
|
||||
|
||||
await provider.callApi('test prompt');
|
||||
|
||||
const chatSpan = spans.find((span) => span.name === 'chat weather-agent');
|
||||
expect(chatSpan).toBeDefined();
|
||||
expect(chatSpan?.attributes).toMatchObject({
|
||||
'gen_ai.system': 'azure',
|
||||
'gen_ai.operation.name': 'chat',
|
||||
});
|
||||
});
|
||||
|
||||
it('emits a chat span but no gen_ai.turn spans on a cache hit', async () => {
|
||||
const spans = installSpanRecorder();
|
||||
const mockCache = {
|
||||
get: vi.fn().mockResolvedValue({ output: 'cached response' }),
|
||||
set: vi.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
vi.mocked(isCacheEnabled).mockReturnValue(true);
|
||||
vi.mocked(getCache).mockResolvedValue(mockCache as any);
|
||||
|
||||
const provider = new AzureFoundryAgentProvider('weather-agent', {
|
||||
config: { projectUrl },
|
||||
});
|
||||
|
||||
const result = await provider.callApi('weather in Paris');
|
||||
|
||||
// A cache hit performs no LLM round, so no gen_ai.turn marker is emitted,
|
||||
// but the request is still wrapped in a chat span (with cache_hit set).
|
||||
expect(result.cached).toBe(true);
|
||||
expect(mockResponsesCreate).not.toHaveBeenCalled();
|
||||
expect(spans.filter((span) => span.name.startsWith('gen_ai.turn '))).toHaveLength(0);
|
||||
const chatSpan = spans.find((span) => span.name === 'chat weather-agent');
|
||||
expect(chatSpan).toBeDefined();
|
||||
expect(chatSpan?.attributes['promptfoo.cache_hit']).toBe(true);
|
||||
});
|
||||
|
||||
it('marks a resolved-but-failed Responses turn as errored', async () => {
|
||||
const spans = installSpanRecorder();
|
||||
mockGetAgent.mockResolvedValue(mockAgent);
|
||||
// The Responses API resolves with a failed status/error object instead of
|
||||
// throwing; the turn marker must reflect that failure, not report OK.
|
||||
mockResponsesCreate.mockResolvedValueOnce({
|
||||
...createMessageResponse('partial'),
|
||||
status: 'failed',
|
||||
error: { message: 'content_filter triggered' },
|
||||
});
|
||||
|
||||
const provider = new AzureFoundryAgentProvider('weather-agent', {
|
||||
config: { projectUrl },
|
||||
});
|
||||
await provider.callApi('test prompt');
|
||||
|
||||
const turnSpan = spans.find((span) => span.name === 'gen_ai.turn 1');
|
||||
expect(turnSpan).toBeDefined();
|
||||
// SpanStatusCode.ERROR === 2
|
||||
expect(turnSpan?.status?.code).toBe(2);
|
||||
expect(turnSpan?.status?.message).toContain('content_filter');
|
||||
});
|
||||
|
||||
it('should return unresolved function calls when callbacks are missing', async () => {
|
||||
mockGetAgent.mockResolvedValue(mockAgent);
|
||||
mockResponsesCreate.mockResolvedValue(createFunctionCallResponse());
|
||||
|
||||
const provider = new AzureFoundryAgentProvider('weather-agent', {
|
||||
config: {
|
||||
projectUrl,
|
||||
},
|
||||
});
|
||||
|
||||
const result = await provider.callApi('test prompt');
|
||||
|
||||
expect(mockResponsesCreate).toHaveBeenCalledTimes(1);
|
||||
expect(result.output).toContain('"type":"function_call"');
|
||||
expect(result.output).toContain('"name":"get_weather"');
|
||||
});
|
||||
|
||||
it('should return error when agent is not found by name or id', async () => {
|
||||
mockGetAgent.mockRejectedValue(new Error('not found'));
|
||||
mockListAgents.mockReturnValue(createAsyncIterable([]));
|
||||
|
||||
const provider = new AzureFoundryAgentProvider('nonexistent-agent', {
|
||||
config: { projectUrl },
|
||||
});
|
||||
|
||||
const result = await provider.callApi('test prompt');
|
||||
|
||||
expect(result.error).toContain("'nonexistent-agent' was not found");
|
||||
expect(result.error).toContain('azure:foundry-agent:<agent-name>');
|
||||
});
|
||||
|
||||
it('should return timeout error when callback loop exceeds maxPollTimeMs', async () => {
|
||||
mockGetAgent.mockResolvedValue(mockAgent);
|
||||
// Always return function calls so the loop never breaks naturally
|
||||
mockResponsesCreate.mockResolvedValue(createFunctionCallResponse());
|
||||
|
||||
const provider = new AzureFoundryAgentProvider('weather-agent', {
|
||||
config: {
|
||||
projectUrl,
|
||||
maxPollTimeMs: 100,
|
||||
functionToolCallbacks: {
|
||||
get_weather: vi.fn().mockResolvedValue('sunny'),
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// Make Date.now() jump past the timeout after the first iteration
|
||||
const originalDateNow = Date.now;
|
||||
let callCount = 0;
|
||||
vi.spyOn(Date, 'now').mockImplementation(() => {
|
||||
callCount++;
|
||||
// First two calls (start + loop check) return 0, then jump past timeout
|
||||
return callCount <= 2 ? 0 : 200;
|
||||
});
|
||||
|
||||
const result = await provider.callApi('test prompt');
|
||||
|
||||
expect(result.error).toContain('tool-calling loop timed out after 100ms');
|
||||
Date.now = originalDateNow;
|
||||
});
|
||||
|
||||
it('should warn once and omit unsupported per-request fields', async () => {
|
||||
mockGetAgent.mockResolvedValue(mockAgent);
|
||||
mockResponsesCreate.mockResolvedValue(createMessageResponse('Test response'));
|
||||
|
||||
const provider = new AzureFoundryAgentProvider('weather-agent', {
|
||||
config: {
|
||||
projectUrl,
|
||||
frequency_penalty: 0.5,
|
||||
presence_penalty: 0.3,
|
||||
retryOptions: { maxRetries: 2 },
|
||||
seed: 123,
|
||||
stop: ['END'],
|
||||
timeoutMs: 1000,
|
||||
tool_resources: {
|
||||
file_search: {
|
||||
vector_store_ids: ['vs_123'],
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await provider.callApi('first prompt');
|
||||
await provider.callApi('second prompt');
|
||||
|
||||
const firstRequestBody = mockResponsesCreate.mock.calls[0][0];
|
||||
expect(firstRequestBody).not.toHaveProperty('frequency_penalty');
|
||||
expect(firstRequestBody).not.toHaveProperty('presence_penalty');
|
||||
expect(firstRequestBody).not.toHaveProperty('retryOptions');
|
||||
expect(firstRequestBody).not.toHaveProperty('seed');
|
||||
expect(firstRequestBody).not.toHaveProperty('stop');
|
||||
expect(firstRequestBody).not.toHaveProperty('timeoutMs');
|
||||
expect(firstRequestBody).not.toHaveProperty('tool_resources');
|
||||
expect(vi.mocked(logger.warn)).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should hash the request body in cache keys', async () => {
|
||||
mockGetAgent.mockResolvedValue(mockAgent);
|
||||
mockResponsesCreate.mockResolvedValue(createMessageResponse('Test response'));
|
||||
|
||||
const mockCache = {
|
||||
get: vi.fn().mockResolvedValue(undefined),
|
||||
set: vi.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
vi.mocked(isCacheEnabled).mockReturnValue(true);
|
||||
vi.mocked(getCache).mockResolvedValue(mockCache as any);
|
||||
|
||||
const secret = 'sk-test-12345678901234567890';
|
||||
const provider = new AzureFoundryAgentProvider('weather-agent', {
|
||||
config: { projectUrl },
|
||||
});
|
||||
|
||||
await provider.callApi(`weather in Paris ${secret}`);
|
||||
|
||||
const cacheKey = mockCache.get.mock.calls[0][0] as string;
|
||||
expect(cacheKey).toMatch(/^azure_foundry_agent:weather-agent:[a-f0-9]{64}:[a-f0-9]{64}$/);
|
||||
expect(cacheKey).not.toContain(projectUrl);
|
||||
expect(cacheKey).not.toContain(secret);
|
||||
expect(cacheKey).not.toContain('weather in Paris');
|
||||
expect(mockCache.set).toHaveBeenCalledWith(cacheKey, expect.any(Object));
|
||||
});
|
||||
|
||||
it('should scope cache keys by project URL', async () => {
|
||||
mockGetAgent.mockResolvedValue(mockAgent);
|
||||
mockResponsesCreate.mockResolvedValue(createMessageResponse('Test response'));
|
||||
|
||||
const mockCache = {
|
||||
get: vi.fn().mockResolvedValue(undefined),
|
||||
set: vi.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
vi.mocked(isCacheEnabled).mockReturnValue(true);
|
||||
vi.mocked(getCache).mockResolvedValue(mockCache as any);
|
||||
|
||||
const prompt = 'same weather prompt';
|
||||
const otherProjectUrl = 'https://test.services.ai.azure.com/api/projects/other-project';
|
||||
const providerA = new AzureFoundryAgentProvider('weather-agent', {
|
||||
config: { projectUrl },
|
||||
});
|
||||
const providerB = new AzureFoundryAgentProvider('weather-agent', {
|
||||
config: { projectUrl: otherProjectUrl },
|
||||
});
|
||||
|
||||
await providerA.callApi(prompt);
|
||||
await providerB.callApi(prompt);
|
||||
|
||||
const [cacheKeyA, cacheKeyB] = mockCache.get.mock.calls.map(([key]) => key as string);
|
||||
expect(cacheKeyA).toMatch(/^azure_foundry_agent:weather-agent:[a-f0-9]{64}:[a-f0-9]{64}$/);
|
||||
expect(cacheKeyB).toMatch(/^azure_foundry_agent:weather-agent:[a-f0-9]{64}:[a-f0-9]{64}$/);
|
||||
expect(cacheKeyA).not.toBe(cacheKeyB);
|
||||
expect(cacheKeyA).not.toContain(projectUrl);
|
||||
expect(cacheKeyB).not.toContain(otherProjectUrl);
|
||||
expect(cacheKeyA).not.toContain(prompt);
|
||||
expect(cacheKeyB).not.toContain(prompt);
|
||||
});
|
||||
|
||||
it('should not log raw prompts on cache hits', async () => {
|
||||
const secret = 'cached-foundry-secret-prompt';
|
||||
const mockCache = {
|
||||
get: vi.fn().mockResolvedValue({ output: 'cached response' }),
|
||||
set: vi.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
vi.mocked(isCacheEnabled).mockReturnValue(true);
|
||||
vi.mocked(getCache).mockResolvedValue(mockCache as any);
|
||||
|
||||
const provider = new AzureFoundryAgentProvider('weather-agent', {
|
||||
config: { projectUrl },
|
||||
});
|
||||
|
||||
await provider.callApi(`weather in Paris ${secret}`);
|
||||
|
||||
const debugLogs = JSON.stringify(vi.mocked(logger.debug).mock.calls);
|
||||
expect(debugLogs).not.toContain(secret);
|
||||
expect(debugLogs).not.toContain('weather in Paris');
|
||||
expect(debugLogs).toContain('Cache hit for Foundry agent response');
|
||||
});
|
||||
});
|
||||
|
||||
describe('error formatting', () => {
|
||||
it('should return guardrails response for content filter errors', async () => {
|
||||
mockGetAgent.mockResolvedValue(mockAgent);
|
||||
mockResponsesCreate.mockRejectedValue(
|
||||
new Error('Content filter triggered: The prompt contained inappropriate content'),
|
||||
);
|
||||
|
||||
const provider = new AzureFoundryAgentProvider('weather-agent', {
|
||||
config: { projectUrl },
|
||||
});
|
||||
|
||||
const result = await provider.callApi('test prompt');
|
||||
|
||||
expect(result.output).toContain('content filtering system');
|
||||
expect(result.guardrails).toEqual({
|
||||
flagged: true,
|
||||
flaggedInput: true,
|
||||
flaggedOutput: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('should format rate limit errors', async () => {
|
||||
mockGetAgent.mockResolvedValue(mockAgent);
|
||||
mockResponsesCreate.mockRejectedValue(new Error('429 Too Many Requests'));
|
||||
|
||||
const provider = new AzureFoundryAgentProvider('weather-agent', {
|
||||
config: { projectUrl },
|
||||
});
|
||||
|
||||
const result = await provider.callApi('test prompt');
|
||||
|
||||
expect(result.error).toContain('Rate limit exceeded');
|
||||
});
|
||||
|
||||
it('should format generic errors', async () => {
|
||||
mockGetAgent.mockResolvedValue(mockAgent);
|
||||
mockResponsesCreate.mockRejectedValue(new Error('Something unexpected happened'));
|
||||
|
||||
const provider = new AzureFoundryAgentProvider('weather-agent', {
|
||||
config: { projectUrl },
|
||||
});
|
||||
|
||||
const result = await provider.callApi('test prompt');
|
||||
|
||||
expect(result.error).toContain('Error in Azure Foundry Agent API call');
|
||||
expect(result.error).toContain('Something unexpected happened');
|
||||
});
|
||||
|
||||
describe('SDK-shape 429 classification (classifySdkRateLimit)', () => {
|
||||
// The OpenAI / Azure SDK rejects with a plain object that has `status`,
|
||||
// not an HttpRateLimitError instance, since it bypasses fetchWithRetries.
|
||||
// We verify formatError still classifies these correctly by `status === 429`
|
||||
// and the body-level error code.
|
||||
|
||||
function makeSdkError(status: number, code?: string, message = 'sdk error'): unknown {
|
||||
return Object.assign(new Error(message), { status, error: code ? { code } : undefined });
|
||||
}
|
||||
|
||||
it('classifies SDK 429 with insufficient_quota body code as quota', async () => {
|
||||
mockGetAgent.mockResolvedValue(mockAgent);
|
||||
mockResponsesCreate.mockRejectedValue(makeSdkError(429, 'insufficient_quota'));
|
||||
const provider = new AzureFoundryAgentProvider('weather-agent', {
|
||||
config: { projectUrl },
|
||||
});
|
||||
const result = await provider.callApi('test prompt');
|
||||
expect(result.error).toContain('Quota exceeded');
|
||||
expect(result.error).toContain('insufficient_quota');
|
||||
expect(result.error).toContain('Retries will not help');
|
||||
});
|
||||
|
||||
it('classifies SDK 429 with rate_limit_exceeded body code as rate_limit', async () => {
|
||||
mockGetAgent.mockResolvedValue(mockAgent);
|
||||
mockResponsesCreate.mockRejectedValue(makeSdkError(429, 'rate_limit_exceeded'));
|
||||
const provider = new AzureFoundryAgentProvider('weather-agent', {
|
||||
config: { projectUrl },
|
||||
});
|
||||
const result = await provider.callApi('test prompt');
|
||||
expect(result.error).toContain('Rate limit exceeded');
|
||||
expect(result.error).toContain('rate_limit_exceeded');
|
||||
expect(result.error).not.toContain('Retries will not help');
|
||||
});
|
||||
|
||||
it('classifies SDK 429 with no body code as rate_limit by default', async () => {
|
||||
mockGetAgent.mockResolvedValue(mockAgent);
|
||||
mockResponsesCreate.mockRejectedValue(makeSdkError(429));
|
||||
const provider = new AzureFoundryAgentProvider('weather-agent', {
|
||||
config: { projectUrl },
|
||||
});
|
||||
const result = await provider.callApi('test prompt');
|
||||
expect(result.error).toContain('Rate limit exceeded');
|
||||
expect(result.error).not.toContain('Retries will not help');
|
||||
});
|
||||
|
||||
it('does not misclassify non-429 SDK errors with insufficient_quota body', async () => {
|
||||
mockGetAgent.mockResolvedValue(mockAgent);
|
||||
// 500 with the same code shouldn't trigger the rate-limit branch
|
||||
mockResponsesCreate.mockRejectedValue(makeSdkError(500, 'insufficient_quota'));
|
||||
const provider = new AzureFoundryAgentProvider('weather-agent', {
|
||||
config: { projectUrl },
|
||||
});
|
||||
const result = await provider.callApi('test prompt');
|
||||
expect(result.error).not.toContain('Quota exceeded');
|
||||
expect(result.error).not.toContain('Rate limit exceeded');
|
||||
});
|
||||
|
||||
it('falls through for non-object errors', async () => {
|
||||
mockGetAgent.mockResolvedValue(mockAgent);
|
||||
mockResponsesCreate.mockRejectedValue('string error');
|
||||
const provider = new AzureFoundryAgentProvider('weather-agent', {
|
||||
config: { projectUrl },
|
||||
});
|
||||
const result = await provider.callApi('test prompt');
|
||||
expect(result.error).toContain('Error in Azure Foundry Agent API call');
|
||||
});
|
||||
|
||||
it('detects 429 nested under err.response.status (older SDK shape)', async () => {
|
||||
// Some SDK wrappers / older openai-node versions nest the response.
|
||||
mockGetAgent.mockResolvedValue(mockAgent);
|
||||
const sdkErr = Object.assign(new Error('sdk wrapper'), {
|
||||
response: { status: 429 },
|
||||
error: { code: 'rate_limit_exceeded' },
|
||||
});
|
||||
mockResponsesCreate.mockRejectedValue(sdkErr);
|
||||
const provider = new AzureFoundryAgentProvider('weather-agent', {
|
||||
config: { projectUrl },
|
||||
});
|
||||
const result = await provider.callApi('test prompt');
|
||||
expect(result.error).toContain('Rate limit exceeded');
|
||||
expect(result.error).toContain('rate_limit_exceeded');
|
||||
});
|
||||
|
||||
it('prefers body-level err.error.code over top-level err.code', async () => {
|
||||
// The OpenAI SDK can set `err.code` to a transport-level value while
|
||||
// the body's error.code is the authoritative API code. Body wins.
|
||||
mockGetAgent.mockResolvedValue(mockAgent);
|
||||
const sdkErr = Object.assign(new Error('sdk shadow'), {
|
||||
status: 429,
|
||||
code: 'ETIMEDOUT',
|
||||
error: { code: 'rate_limit_exceeded' },
|
||||
});
|
||||
mockResponsesCreate.mockRejectedValue(sdkErr);
|
||||
const provider = new AzureFoundryAgentProvider('weather-agent', {
|
||||
config: { projectUrl },
|
||||
});
|
||||
const result = await provider.callApi('test prompt');
|
||||
expect(result.error).toContain('Rate limit exceeded');
|
||||
expect(result.error).toContain('rate_limit_exceeded');
|
||||
});
|
||||
|
||||
it('populates metadata.rateLimitKind on SDK 429 errors', async () => {
|
||||
mockGetAgent.mockResolvedValue(mockAgent);
|
||||
mockResponsesCreate.mockRejectedValue(makeSdkError(429, 'insufficient_quota'));
|
||||
const provider = new AzureFoundryAgentProvider('weather-agent', {
|
||||
config: { projectUrl },
|
||||
});
|
||||
const result = await provider.callApi('test prompt');
|
||||
expect(result.metadata?.rateLimitKind).toBe('quota');
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,107 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { AzureGenericProvider } from '../../../src/providers/azure/generic';
|
||||
import { mockProcessEnv } from '../../util/utils';
|
||||
|
||||
describe('AzureGenericProvider', () => {
|
||||
describe('getApiBaseUrl', () => {
|
||||
let restoreEnv: () => void;
|
||||
|
||||
beforeEach(() => {
|
||||
restoreEnv = mockProcessEnv({ AZURE_OPENAI_API_HOST: undefined });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
restoreEnv();
|
||||
});
|
||||
|
||||
it('should return apiBaseUrl if set', () => {
|
||||
const provider = new AzureGenericProvider('test-deployment', {
|
||||
config: { apiBaseUrl: 'https://custom.azure.com' },
|
||||
});
|
||||
expect(provider.getApiBaseUrl()).toBe('https://custom.azure.com');
|
||||
});
|
||||
|
||||
it('should return apiBaseUrl without trailing slash if set', () => {
|
||||
const provider = new AzureGenericProvider('test-deployment', {
|
||||
config: { apiBaseUrl: 'https://custom.azure.com/' },
|
||||
});
|
||||
expect(provider.getApiBaseUrl()).toBe('https://custom.azure.com');
|
||||
});
|
||||
|
||||
it('should construct URL from apiHost without protocol', () => {
|
||||
const provider = new AzureGenericProvider('test-deployment', {
|
||||
config: { apiHost: 'api.azure.com' },
|
||||
});
|
||||
expect(provider.getApiBaseUrl()).toBe('https://api.azure.com');
|
||||
});
|
||||
|
||||
it('should remove protocol from apiHost if present', () => {
|
||||
const provider = new AzureGenericProvider('test-deployment', {
|
||||
config: { apiHost: 'https://api.azure.com' },
|
||||
});
|
||||
expect(provider.getApiBaseUrl()).toBe('https://api.azure.com');
|
||||
});
|
||||
|
||||
it('should remove trailing slash from apiHost if present', () => {
|
||||
const provider = new AzureGenericProvider('test-deployment', {
|
||||
config: { apiHost: 'api.azure.com/' },
|
||||
});
|
||||
expect(provider.getApiBaseUrl()).toBe('https://api.azure.com');
|
||||
});
|
||||
|
||||
it('should return undefined if neither apiBaseUrl nor apiHost is set', () => {
|
||||
const provider = new AzureGenericProvider('test-deployment', {});
|
||||
expect(provider.getApiBaseUrl()).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Entra ID token refresh', () => {
|
||||
let restoreEnv: () => void;
|
||||
beforeEach(() => {
|
||||
restoreEnv = mockProcessEnv({ AZURE_API_KEY: undefined, AZURE_OPENAI_API_KEY: undefined });
|
||||
});
|
||||
afterEach(() => {
|
||||
restoreEnv();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('does not re-fetch api-key auth headers on subsequent requests', async () => {
|
||||
const spy = vi
|
||||
.spyOn(AzureGenericProvider.prototype as any, 'getAuthHeaders')
|
||||
.mockResolvedValue({ 'api-key': 'k' });
|
||||
const p = new AzureGenericProvider('d', { config: { apiKey: 'k' } });
|
||||
await p.ensureInitialized();
|
||||
const callsAfterInit = spy.mock.calls.length;
|
||||
await p.ensureInitialized();
|
||||
await p.ensureInitialized();
|
||||
expect(spy.mock.calls.length).toBe(callsAfterInit); // api-key never refreshes
|
||||
});
|
||||
|
||||
it('refreshes a bearer token that is at/near expiry', async () => {
|
||||
const spy = vi
|
||||
.spyOn(AzureGenericProvider.prototype as any, 'getAuthHeaders')
|
||||
.mockResolvedValueOnce({ Authorization: 'Bearer first' })
|
||||
.mockResolvedValue({ Authorization: 'Bearer refreshed' });
|
||||
const p = new AzureGenericProvider('d', {});
|
||||
await p.ensureInitialized();
|
||||
expect((p as any).authHeaders).toEqual({ Authorization: 'Bearer first' });
|
||||
// Simulate the captured token being already expired.
|
||||
(p as any).authTokenExpiresOnTimestamp = Date.now() - 1000;
|
||||
await p.ensureInitialized();
|
||||
expect((p as any).authHeaders).toEqual({ Authorization: 'Bearer refreshed' });
|
||||
expect(spy).toHaveBeenCalledTimes(2); // once at init, once on refresh
|
||||
});
|
||||
|
||||
it('does not refresh a still-valid bearer token', async () => {
|
||||
const spy = vi
|
||||
.spyOn(AzureGenericProvider.prototype as any, 'getAuthHeaders')
|
||||
.mockResolvedValue({ Authorization: 'Bearer t' });
|
||||
const p = new AzureGenericProvider('d', {});
|
||||
await p.ensureInitialized();
|
||||
(p as any).authTokenExpiresOnTimestamp = Date.now() + 60 * 60 * 1000; // valid for ~1h
|
||||
const callsAfterInit = spy.mock.calls.length;
|
||||
await p.ensureInitialized();
|
||||
expect(spy.mock.calls.length).toBe(callsAfterInit); // no refetch while valid
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,300 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { fetchWithCache } from '../../../src/cache';
|
||||
import {
|
||||
AzureImageProvider,
|
||||
summarizeImageResponse,
|
||||
validateMaiImageDimensions,
|
||||
} from '../../../src/providers/azure/image';
|
||||
|
||||
vi.mock('../../../src/cache', async (importOriginal) => {
|
||||
return {
|
||||
...(await importOriginal()),
|
||||
fetchWithCache: vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
const FAKE_B64 = Buffer.from('fake-png-bytes').toString('base64');
|
||||
|
||||
function makeProvider(config: Record<string, any> = {}) {
|
||||
return new AzureImageProvider('mai-image-2-5', {
|
||||
config: {
|
||||
apiHost: 'res.services.ai.azure.com',
|
||||
apiKey: 'test-key',
|
||||
...config,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function mockSuccess(overrides: Record<string, any> = {}) {
|
||||
vi.mocked(fetchWithCache).mockResolvedValueOnce({
|
||||
data: {
|
||||
created: 1,
|
||||
data: [{ b64_json: FAKE_B64, revised_prompt: 'a revised prompt' }],
|
||||
model: 'mai-image-2-5',
|
||||
size: '1024x1024',
|
||||
// Current API shape: token counts live under `usage`.
|
||||
usage: { num_output_tokens: 1024, num_input_text_tokens: 0, num_input_image_tokens: 0 },
|
||||
...overrides,
|
||||
},
|
||||
cached: false,
|
||||
status: 200,
|
||||
statusText: 'OK',
|
||||
latencyMs: 123,
|
||||
} as any);
|
||||
}
|
||||
|
||||
describe('validateMaiImageDimensions', () => {
|
||||
it('accepts valid square dimensions', () => {
|
||||
expect(validateMaiImageDimensions(1024, 1024)).toEqual({ valid: true });
|
||||
expect(validateMaiImageDimensions(768, 768)).toEqual({ valid: true });
|
||||
});
|
||||
|
||||
it('rejects dimensions below the 768px minimum', () => {
|
||||
const result = validateMaiImageDimensions(256, 256);
|
||||
expect(result.valid).toBe(false);
|
||||
expect(result.message).toMatch(/at least 768/);
|
||||
});
|
||||
|
||||
it('rejects images whose pixel count exceeds the maximum', () => {
|
||||
const result = validateMaiImageDimensions(2048, 2048);
|
||||
expect(result.valid).toBe(false);
|
||||
expect(result.message).toMatch(/must not exceed/);
|
||||
});
|
||||
|
||||
it('rejects non-integer / non-positive dimensions', () => {
|
||||
expect(validateMaiImageDimensions(0, 1024).valid).toBe(false);
|
||||
expect(validateMaiImageDimensions(1024.5, 1024).valid).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('summarizeImageResponse', () => {
|
||||
it('replaces base64 payloads with their length and keeps other fields', () => {
|
||||
const summary = summarizeImageResponse({
|
||||
data: [{ b64_json: 'a'.repeat(5000), revised_prompt: 'p' }],
|
||||
usage: { num_output_tokens: 10 },
|
||||
});
|
||||
expect(summary).not.toContain('aaaa');
|
||||
expect(summary).toContain('<5000 base64 chars>');
|
||||
expect(summary).toContain('revised_prompt');
|
||||
expect(summary).toContain('num_output_tokens');
|
||||
});
|
||||
|
||||
it('falls back to plain JSON for non-image-shaped values', () => {
|
||||
expect(summarizeImageResponse({ error: { code: 'X' } })).toBe('{"error":{"code":"X"}}');
|
||||
});
|
||||
});
|
||||
|
||||
describe('AzureImageProvider', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('reports id and toString', () => {
|
||||
const provider = makeProvider();
|
||||
expect(provider.id()).toBe('azure:image:mai-image-2-5');
|
||||
expect(provider.toString()).toBe('[Azure Image Provider mai-image-2-5]');
|
||||
});
|
||||
|
||||
it('generates an image and returns a PNG data URL with structured images', async () => {
|
||||
mockSuccess();
|
||||
const provider = makeProvider({ model: 'MAI-Image-2.5' });
|
||||
const result = await provider.callApi('a red cube');
|
||||
|
||||
expect(result.error).toBeUndefined();
|
||||
expect(result.output).toBe(`data:image/png;base64,${FAKE_B64}`);
|
||||
expect(result.images).toEqual([
|
||||
{ data: `data:image/png;base64,${FAKE_B64}`, mimeType: 'image/png' },
|
||||
]);
|
||||
expect(result.isBase64).toBe(true);
|
||||
expect(result.tokenUsage).toEqual({
|
||||
prompt: 0,
|
||||
completion: 1024,
|
||||
total: 1024,
|
||||
numRequests: 1,
|
||||
});
|
||||
expect(result.metadata).toMatchObject({
|
||||
revisedPrompt: 'a revised prompt',
|
||||
size: '1024x1024',
|
||||
model: 'mai-image-2-5',
|
||||
});
|
||||
});
|
||||
|
||||
it('posts to the /mai/v1/images/generations route with the deployment name and dimensions', async () => {
|
||||
mockSuccess();
|
||||
const provider = makeProvider({ width: 768, height: 1024 });
|
||||
await provider.callApi('a blue sphere');
|
||||
|
||||
expect(fetchWithCache).toHaveBeenCalledTimes(1);
|
||||
const [url, options] = vi.mocked(fetchWithCache).mock.calls[0];
|
||||
expect(url).toBe('https://res.services.ai.azure.com/mai/v1/images/generations');
|
||||
const body = JSON.parse((options as any).body);
|
||||
expect(body).toMatchObject({
|
||||
model: 'mai-image-2-5',
|
||||
prompt: 'a blue sphere',
|
||||
width: 768,
|
||||
height: 1024,
|
||||
});
|
||||
expect((options as any).headers).toMatchObject({
|
||||
'Content-Type': 'application/json',
|
||||
'api-key': 'test-key',
|
||||
});
|
||||
});
|
||||
|
||||
it('sends per-prompt config headers (uses merged config, not just provider headers)', async () => {
|
||||
mockSuccess();
|
||||
const provider = makeProvider({ headers: { 'x-custom': 'base' } });
|
||||
await provider.callApi('hi', {
|
||||
prompt: {
|
||||
raw: 'hi',
|
||||
label: 'hi',
|
||||
config: { headers: { 'x-custom': 'override', 'x-extra': 'yes' } },
|
||||
},
|
||||
} as any);
|
||||
const headers = (vi.mocked(fetchWithCache).mock.calls[0][1] as any).headers;
|
||||
expect(headers).toMatchObject({
|
||||
'api-key': 'test-key',
|
||||
'x-custom': 'override',
|
||||
'x-extra': 'yes',
|
||||
});
|
||||
});
|
||||
|
||||
it('computes cost from num_output_tokens when model is mapped', async () => {
|
||||
mockSuccess();
|
||||
const provider = makeProvider({ model: 'MAI-Image-2.5' });
|
||||
const result = await provider.callApi('a red cube');
|
||||
// 1024 tokens * $33 / 1,000,000 output tokens
|
||||
expect(result.cost).toBeCloseTo(0.033792, 6);
|
||||
});
|
||||
|
||||
it('omits cost when the deployment name is not a known model id', async () => {
|
||||
mockSuccess();
|
||||
const provider = makeProvider(); // no `model` override; deployment name unknown
|
||||
const result = await provider.callApi('a red cube');
|
||||
expect(result.cost).toBeUndefined();
|
||||
// Token usage is still reported even without a cost rate.
|
||||
expect(result.tokenUsage).toEqual({
|
||||
prompt: 0,
|
||||
completion: 1024,
|
||||
total: 1024,
|
||||
numRequests: 1,
|
||||
});
|
||||
});
|
||||
|
||||
it('prices input (text + image) tokens from the usage object', async () => {
|
||||
mockSuccess({
|
||||
usage: { num_output_tokens: 1024, num_input_text_tokens: 100, num_input_image_tokens: 0 },
|
||||
});
|
||||
const provider = makeProvider({ model: 'MAI-Image-2.5' });
|
||||
const result = await provider.callApi('a red cube');
|
||||
// 100 input * $5/1M + 1024 output * $33/1M
|
||||
expect(result.cost).toBeCloseTo(100 * (5 / 1e6) + 1024 * (33 / 1e6), 9);
|
||||
expect(result.tokenUsage).toEqual({
|
||||
prompt: 100,
|
||||
completion: 1024,
|
||||
total: 1124,
|
||||
numRequests: 1,
|
||||
});
|
||||
});
|
||||
|
||||
it('supports the legacy top-level num_output_tokens shape', async () => {
|
||||
// Older API responses reported tokens at the top level without a usage object.
|
||||
mockSuccess({ usage: undefined, num_output_tokens: 512 });
|
||||
const provider = makeProvider({ model: 'MAI-Image-2.5' });
|
||||
const result = await provider.callApi('a red cube');
|
||||
expect(result.cost).toBeCloseTo(512 * (33 / 1e6), 9);
|
||||
expect(result.tokenUsage).toEqual({
|
||||
prompt: 0,
|
||||
completion: 512,
|
||||
total: 512,
|
||||
numRequests: 1,
|
||||
});
|
||||
});
|
||||
|
||||
it('passes through extra body params and custom dimensions', async () => {
|
||||
mockSuccess();
|
||||
const provider = makeProvider({ passthrough: { seed: 42 } });
|
||||
await provider.callApi('something');
|
||||
const body = JSON.parse((vi.mocked(fetchWithCache).mock.calls[0][1] as any).body);
|
||||
expect(body.seed).toBe(42);
|
||||
});
|
||||
|
||||
it('validates dimensions before calling the API', async () => {
|
||||
const provider = makeProvider({ width: 256, height: 256 });
|
||||
const result = await provider.callApi('too small');
|
||||
expect(result.error).toMatch(/at least 768/);
|
||||
expect(fetchWithCache).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('surfaces a structured API error and evicts it from the cache', async () => {
|
||||
// `deleteFromCache` is returned as a sibling of `data` by fetchWithCache,
|
||||
// not on the parsed body — the provider must call the captured handle.
|
||||
const deleteFromCache = vi.fn();
|
||||
vi.mocked(fetchWithCache).mockResolvedValueOnce({
|
||||
data: {
|
||||
error: {
|
||||
code: 'unsupported_request_value',
|
||||
message: "'width' must be at least 768 pixels.",
|
||||
},
|
||||
},
|
||||
deleteFromCache,
|
||||
cached: false,
|
||||
status: 400,
|
||||
statusText: 'Bad Request',
|
||||
} as any);
|
||||
|
||||
const provider = makeProvider();
|
||||
const result = await provider.callApi('boom');
|
||||
expect(result.error).toContain('unsupported_request_value');
|
||||
expect(result.error).toContain('must be at least 768 pixels');
|
||||
expect(deleteFromCache).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('evicts malformed 2xx responses (missing image data) from the cache', async () => {
|
||||
const deleteFromCache = vi.fn();
|
||||
vi.mocked(fetchWithCache).mockResolvedValueOnce({
|
||||
data: { data: [{}], num_output_tokens: 0 },
|
||||
deleteFromCache,
|
||||
cached: false,
|
||||
status: 200,
|
||||
statusText: 'OK',
|
||||
} as any);
|
||||
|
||||
const provider = makeProvider();
|
||||
const result = await provider.callApi('no image');
|
||||
expect(result.error).toMatch(/No image data found/);
|
||||
expect(deleteFromCache).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('surfaces non-2xx responses without a structured error body', async () => {
|
||||
vi.mocked(fetchWithCache).mockResolvedValueOnce({
|
||||
data: 'upstream exploded',
|
||||
cached: false,
|
||||
status: 502,
|
||||
statusText: 'Bad Gateway',
|
||||
} as any);
|
||||
|
||||
const provider = makeProvider();
|
||||
const result = await provider.callApi('boom');
|
||||
expect(result.error).toContain('502');
|
||||
expect(result.error).toContain('Bad Gateway');
|
||||
});
|
||||
|
||||
it('marks cached responses and zeroes their cost', async () => {
|
||||
vi.mocked(fetchWithCache).mockResolvedValueOnce({
|
||||
data: {
|
||||
data: [{ b64_json: FAKE_B64 }],
|
||||
num_output_tokens: 1024,
|
||||
size: '1024x1024',
|
||||
},
|
||||
cached: true,
|
||||
status: 200,
|
||||
statusText: 'OK',
|
||||
} as any);
|
||||
|
||||
const provider = makeProvider({ model: 'MAI-Image-2.5' });
|
||||
const result = await provider.callApi('cached cube');
|
||||
expect(result.cached).toBe(true);
|
||||
expect(result.cost).toBe(0);
|
||||
expect(result.tokenUsage).toEqual({ cached: 1024, total: 1024 });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,112 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { AzureCompletionProvider } from '../../../src/providers/azure/completion';
|
||||
import { maybeEmitAzureOpenAiWarning } from '../../../src/providers/azure/warnings';
|
||||
import { HuggingfaceTextGenerationProvider } from '../../../src/providers/huggingface';
|
||||
import { OpenAiCompletionProvider } from '../../../src/providers/openai/completion';
|
||||
|
||||
import type { TestCase, TestSuite } from '../../../src/types/index';
|
||||
|
||||
vi.mock('../../../src/cache', async (importOriginal) => {
|
||||
return {
|
||||
...(await importOriginal()),
|
||||
fetchWithCache: vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
describe('maybeEmitAzureOpenAiWarning', () => {
|
||||
it('should not emit warning when no Azure providers are used', () => {
|
||||
const testSuite: TestSuite = {
|
||||
providers: [new OpenAiCompletionProvider('foo')],
|
||||
defaultTest: {},
|
||||
prompts: [],
|
||||
};
|
||||
const tests: TestCase[] = [
|
||||
{
|
||||
assert: [{ type: 'llm-rubric', value: 'foo bar' }],
|
||||
},
|
||||
];
|
||||
const result = maybeEmitAzureOpenAiWarning(testSuite, tests);
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
it('should not emit warning when Azure provider is used alone, but no model graded eval', () => {
|
||||
const testSuite: TestSuite = {
|
||||
providers: [new AzureCompletionProvider('foo', { config: { apiHost: 'test.azure.com' } })],
|
||||
defaultTest: {},
|
||||
prompts: [],
|
||||
};
|
||||
const tests: TestCase[] = [
|
||||
{
|
||||
assert: [{ type: 'equals' }],
|
||||
},
|
||||
];
|
||||
const result = maybeEmitAzureOpenAiWarning(testSuite, tests);
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
it('should emit warning when Azure provider is used alone, but with model graded eval', () => {
|
||||
const testSuite: TestSuite = {
|
||||
providers: [new AzureCompletionProvider('foo', { config: { apiHost: 'test.azure.com' } })],
|
||||
defaultTest: {},
|
||||
prompts: [],
|
||||
};
|
||||
const tests: TestCase[] = [
|
||||
{
|
||||
assert: [{ type: 'llm-rubric', value: 'foo bar' }],
|
||||
},
|
||||
];
|
||||
const result = maybeEmitAzureOpenAiWarning(testSuite, tests);
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
it('should emit warning when Azure provider used with non-OpenAI provider', () => {
|
||||
const testSuite: TestSuite = {
|
||||
providers: [
|
||||
new AzureCompletionProvider('foo', { config: { apiHost: 'test.azure.com' } }),
|
||||
new HuggingfaceTextGenerationProvider('bar'),
|
||||
],
|
||||
defaultTest: {},
|
||||
prompts: [],
|
||||
};
|
||||
const tests: TestCase[] = [
|
||||
{
|
||||
assert: [{ type: 'llm-rubric', value: 'foo bar' }],
|
||||
},
|
||||
];
|
||||
const result = maybeEmitAzureOpenAiWarning(testSuite, tests);
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
it('should not emit warning when Azure providers are used with a default provider set', () => {
|
||||
const testSuite: TestSuite = {
|
||||
providers: [new AzureCompletionProvider('foo', { config: { apiHost: 'test.azure.com' } })],
|
||||
defaultTest: { options: { provider: 'azureopenai:....' } },
|
||||
prompts: [],
|
||||
};
|
||||
const tests: TestCase[] = [
|
||||
{
|
||||
assert: [{ type: 'llm-rubric', value: 'foo bar' }],
|
||||
},
|
||||
];
|
||||
const result = maybeEmitAzureOpenAiWarning(testSuite, tests);
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
it('should not emit warning when both Azure and OpenAI providers are used', () => {
|
||||
const testSuite: TestSuite = {
|
||||
providers: [
|
||||
new AzureCompletionProvider('foo', { config: { apiHost: 'test.azure.com' } }),
|
||||
new OpenAiCompletionProvider('bar'),
|
||||
],
|
||||
defaultTest: {},
|
||||
prompts: [],
|
||||
};
|
||||
const tests: TestCase[] = [
|
||||
{
|
||||
assert: [{ type: 'llm-rubric', value: 'foo bar' }],
|
||||
},
|
||||
];
|
||||
const result = maybeEmitAzureOpenAiWarning(testSuite, tests);
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,364 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { getCache, isCacheEnabled } from '../../../src/cache';
|
||||
import {
|
||||
type AzureModerationCategory,
|
||||
AzureModerationProvider,
|
||||
getModerationCacheKey,
|
||||
handleApiError,
|
||||
parseAzureModerationResponse,
|
||||
} from '../../../src/providers/azure/moderation';
|
||||
|
||||
vi.mock('../../../src/cache');
|
||||
vi.mock('../../../src/util/fetch/index');
|
||||
|
||||
describe('Azure Moderation', () => {
|
||||
describe('parseAzureModerationResponse', () => {
|
||||
it('should parse valid response with categories', () => {
|
||||
const response = {
|
||||
categoriesAnalysis: [
|
||||
{
|
||||
category: 'Hate' as AzureModerationCategory,
|
||||
severity: 4,
|
||||
},
|
||||
{
|
||||
category: 'Sexual' as AzureModerationCategory,
|
||||
severity: 0,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const result = parseAzureModerationResponse(response);
|
||||
|
||||
expect(result).toEqual({
|
||||
flags: [
|
||||
{
|
||||
code: 'hate',
|
||||
description: 'Content flagged for Hate',
|
||||
confidence: 4 / 7,
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle null/undefined response', () => {
|
||||
expect(parseAzureModerationResponse(null as any)).toEqual({ flags: [] });
|
||||
expect(parseAzureModerationResponse(undefined as any)).toEqual({ flags: [] });
|
||||
});
|
||||
|
||||
it('should handle empty categories', () => {
|
||||
const response = {
|
||||
categoriesAnalysis: [],
|
||||
};
|
||||
|
||||
expect(parseAzureModerationResponse(response)).toEqual({ flags: [] });
|
||||
});
|
||||
|
||||
it('should handle multiple categories with severity', () => {
|
||||
const response = {
|
||||
categoriesAnalysis: [
|
||||
{
|
||||
category: 'Hate' as AzureModerationCategory,
|
||||
severity: 3,
|
||||
},
|
||||
{
|
||||
category: 'Violence' as AzureModerationCategory,
|
||||
severity: 5,
|
||||
},
|
||||
{
|
||||
category: 'Sexual' as AzureModerationCategory,
|
||||
severity: 0,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const result = parseAzureModerationResponse(response);
|
||||
|
||||
expect(result).toEqual({
|
||||
flags: [
|
||||
{
|
||||
code: 'hate',
|
||||
description: 'Content flagged for Hate',
|
||||
confidence: 3 / 7,
|
||||
},
|
||||
{
|
||||
code: 'violence',
|
||||
description: 'Content flagged for Violence',
|
||||
confidence: 5 / 7,
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle parsing errors', () => {
|
||||
const malformedResponse = Object.create(null);
|
||||
Object.defineProperty(malformedResponse, 'categoriesAnalysis', {
|
||||
get() {
|
||||
throw new Error('Invalid access');
|
||||
},
|
||||
});
|
||||
|
||||
const result = parseAzureModerationResponse(malformedResponse as any);
|
||||
expect(result.flags).toEqual([]);
|
||||
expect(result.error).toBe('Failed to parse moderation response');
|
||||
});
|
||||
|
||||
it('should handle both categories and blocklist matches', () => {
|
||||
const response = {
|
||||
categoriesAnalysis: [
|
||||
{
|
||||
category: 'Hate' as AzureModerationCategory,
|
||||
severity: 5,
|
||||
},
|
||||
],
|
||||
blocklistsMatch: [
|
||||
{
|
||||
blocklistName: 'custom-list',
|
||||
blocklistItemId: '456',
|
||||
blocklistItemText: 'forbidden term',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const result = parseAzureModerationResponse(response);
|
||||
|
||||
expect(result.flags).toEqual([
|
||||
{
|
||||
code: 'hate',
|
||||
description: 'Content flagged for Hate',
|
||||
confidence: 5 / 7,
|
||||
},
|
||||
{
|
||||
code: 'blocklist:custom-list',
|
||||
description: 'Content matched blocklist item: forbidden term',
|
||||
confidence: 1.0,
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('handleApiError', () => {
|
||||
it('should format error with message', () => {
|
||||
const error = new Error('API failure');
|
||||
const result = handleApiError(error);
|
||||
|
||||
expect(result).toEqual({
|
||||
error: 'API failure',
|
||||
flags: [],
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle error without message', () => {
|
||||
const result = handleApiError({});
|
||||
|
||||
expect(result).toEqual({
|
||||
error: 'Unknown error',
|
||||
flags: [],
|
||||
});
|
||||
});
|
||||
|
||||
it('should include additional data if provided', () => {
|
||||
const error = new Error('API error');
|
||||
const data = { detail: 'Additional info' };
|
||||
const result = handleApiError(error, data);
|
||||
|
||||
expect(result.error).toBe('API error');
|
||||
expect(result.flags).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getModerationCacheKey', () => {
|
||||
it('should generate correct cache key', () => {
|
||||
const modelName = 'test-model';
|
||||
const config = { apiKey: 'test-key' };
|
||||
const content = 'test content';
|
||||
|
||||
const key = getModerationCacheKey(modelName, config, content);
|
||||
|
||||
expect(key).toMatch(/^azure-moderation:test-model:[a-f0-9]{64}:[a-f0-9]{64}$/);
|
||||
expect(key).not.toContain(content);
|
||||
expect(key).not.toContain('test-key');
|
||||
});
|
||||
|
||||
it('should handle empty content', () => {
|
||||
const key = getModerationCacheKey('model', {}, '');
|
||||
expect(key).toMatch(/^azure-moderation:model:[a-f0-9]{64}:[a-f0-9]{64}$/);
|
||||
});
|
||||
|
||||
it('should differentiate content without leaking it', () => {
|
||||
const firstKey = getModerationCacheKey('model', {}, 'sensitive prompt one');
|
||||
const secondKey = getModerationCacheKey('model', {}, 'sensitive prompt two');
|
||||
|
||||
expect(firstKey).not.toBe(secondKey);
|
||||
expect(firstKey).not.toContain('sensitive prompt one');
|
||||
expect(secondKey).not.toContain('sensitive prompt two');
|
||||
});
|
||||
|
||||
it('should include request-shaping config in the cache key', () => {
|
||||
const defaultKey = getModerationCacheKey('model', {}, 'content');
|
||||
const key = getModerationCacheKey(
|
||||
'model',
|
||||
{
|
||||
blocklistNames: ['custom-list'],
|
||||
haltOnBlocklistHit: true,
|
||||
passthrough: { outputType: 'EightSeverityLevels' },
|
||||
},
|
||||
'content',
|
||||
);
|
||||
|
||||
expect(key).toMatch(/^azure-moderation:model:[a-f0-9]{64}:[a-f0-9]{64}$/);
|
||||
expect(key).not.toBe(defaultKey);
|
||||
expect(key).not.toContain('content');
|
||||
expect(key).not.toContain('custom-list');
|
||||
expect(key).not.toContain('EightSeverityLevels');
|
||||
});
|
||||
|
||||
it('should include endpoint and apiVersion in the cache key', () => {
|
||||
const firstKey = getModerationCacheKey(
|
||||
'model',
|
||||
{ endpoint: 'https://resource-a.cognitiveservices.azure.com/' },
|
||||
'content',
|
||||
);
|
||||
const secondKey = getModerationCacheKey(
|
||||
'model',
|
||||
{ endpoint: 'https://resource-b.cognitiveservices.azure.com/' },
|
||||
'content',
|
||||
);
|
||||
|
||||
expect(firstKey).not.toBe(secondKey);
|
||||
expect(firstKey).not.toContain('resource-a');
|
||||
expect(secondKey).not.toContain('resource-b');
|
||||
|
||||
const versionKey1 = getModerationCacheKey('model', { apiVersion: '2024-09-01' }, 'content');
|
||||
const versionKey2 = getModerationCacheKey(
|
||||
'model',
|
||||
{ apiVersion: '2024-09-15-preview' },
|
||||
'content',
|
||||
);
|
||||
|
||||
expect(versionKey1).not.toBe(versionKey2);
|
||||
expect(versionKey1).not.toContain('2024-09-01');
|
||||
expect(versionKey2).not.toContain('2024-09-15-preview');
|
||||
});
|
||||
|
||||
it('should ignore apiKey and apiKeyEnvar in the cache key', () => {
|
||||
const firstKey = getModerationCacheKey(
|
||||
'model',
|
||||
{ apiKey: 'key-1', apiKeyEnvar: 'MY_KEY_1', endpoint: 'https://test.com' },
|
||||
'content',
|
||||
);
|
||||
const secondKey = getModerationCacheKey(
|
||||
'model',
|
||||
{ apiKey: 'key-2', apiKeyEnvar: 'MY_KEY_2', endpoint: 'https://test.com' },
|
||||
'content',
|
||||
);
|
||||
|
||||
expect(firstKey).toBe(secondKey);
|
||||
});
|
||||
|
||||
it('should differentiate by headers without leaking raw values', () => {
|
||||
const firstKey = getModerationCacheKey(
|
||||
'model',
|
||||
{ endpoint: 'https://test.com', headers: { Authorization: 'Bearer secret-token-1' } },
|
||||
'content',
|
||||
);
|
||||
const secondKey = getModerationCacheKey(
|
||||
'model',
|
||||
{ endpoint: 'https://test.com', headers: { Authorization: 'Bearer secret-token-2' } },
|
||||
'content',
|
||||
);
|
||||
|
||||
expect(firstKey).not.toBe(secondKey);
|
||||
expect(firstKey).not.toContain('secret-token-1');
|
||||
expect(secondKey).not.toContain('secret-token-2');
|
||||
expect(firstKey).not.toContain('https://test.com');
|
||||
});
|
||||
|
||||
it('should treat empty headers the same as absent headers', () => {
|
||||
const noHeaders = getModerationCacheKey('model', {}, 'content');
|
||||
const emptyHeaders = getModerationCacheKey('model', { headers: {} }, 'content');
|
||||
const undefinedHeaders = getModerationCacheKey('model', { headers: undefined }, 'content');
|
||||
|
||||
expect(noHeaders).toBe(emptyHeaders);
|
||||
expect(noHeaders).toBe(undefinedHeaders);
|
||||
});
|
||||
|
||||
it('should produce the same hash regardless of header key order', () => {
|
||||
const key1 = getModerationCacheKey('model', { headers: { A: '1', B: '2' } }, 'content');
|
||||
const key2 = getModerationCacheKey('model', { headers: { B: '2', A: '1' } }, 'content');
|
||||
|
||||
expect(key1).toBe(key2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('AzureModerationProvider', () => {
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
vi.mocked(isCacheEnabled).mockReturnValue(false);
|
||||
});
|
||||
|
||||
it('should set cached flag when returning cached response', async () => {
|
||||
const mockCachedResponse = {
|
||||
flags: [
|
||||
{
|
||||
code: 'hate',
|
||||
description: 'Content flagged for Hate',
|
||||
confidence: 0.8,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const mockCache = {
|
||||
get: vi.fn().mockResolvedValue(mockCachedResponse),
|
||||
set: vi.fn(),
|
||||
} as any;
|
||||
|
||||
vi.mocked(isCacheEnabled).mockReturnValue(true);
|
||||
vi.mocked(getCache).mockResolvedValue(mockCache);
|
||||
|
||||
const provider = new AzureModerationProvider('text-content-safety', {
|
||||
config: {
|
||||
apiKey: 'test-key',
|
||||
endpoint: 'https://test.cognitiveservices.azure.com/',
|
||||
},
|
||||
});
|
||||
|
||||
const result = await provider.callModerationApi('user prompt', 'assistant response');
|
||||
|
||||
expect(result.cached).toBe(true);
|
||||
expect(result.flags).toEqual(mockCachedResponse.flags);
|
||||
expect(mockCache.get).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should use resolved endpoint and apiVersion in cache key', async () => {
|
||||
const mockCache = {
|
||||
get: vi.fn().mockResolvedValue(null),
|
||||
set: vi.fn(),
|
||||
} as any;
|
||||
|
||||
vi.mocked(isCacheEnabled).mockReturnValue(true);
|
||||
vi.mocked(getCache).mockResolvedValue(mockCache);
|
||||
|
||||
const { fetchWithProxy } = await import('../../../src/util/fetch/index');
|
||||
vi.mocked(fetchWithProxy).mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({ categoriesAnalysis: [] }),
|
||||
} as any);
|
||||
|
||||
const provider = new AzureModerationProvider('text-content-safety', {
|
||||
config: {
|
||||
apiKey: 'test-key',
|
||||
endpoint: 'https://resolved-endpoint.cognitiveservices.azure.com/',
|
||||
apiVersion: '2024-09-15-preview',
|
||||
},
|
||||
});
|
||||
|
||||
await provider.callModerationApi('user prompt', 'test content');
|
||||
|
||||
const cacheKey = mockCache.get.mock.calls[0][0] as string;
|
||||
expect(cacheKey).toMatch(/^azure-moderation:text-content-safety:[a-f0-9]{64}:[a-f0-9]{64}$/);
|
||||
expect(cacheKey).not.toContain('resolved-endpoint');
|
||||
expect(cacheKey).not.toContain('2024-09-15-preview');
|
||||
expect(cacheKey).not.toContain('test content');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,216 @@
|
||||
/**
|
||||
* Integration test for Azure Responses API nested schema loading.
|
||||
*
|
||||
* This test verifies the bug fix where nested `schema: file://...` references
|
||||
* inside response_format configurations were not being loaded correctly.
|
||||
*
|
||||
* Unlike the unit tests in responses.test.ts which mock maybeLoadResponseFormatFromExternalFile,
|
||||
* this integration test uses real file system operations to verify the full loading chain.
|
||||
*/
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
|
||||
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { AzureResponsesProvider } from '../../../src/providers/azure/responses';
|
||||
import { mockProcessEnv } from '../../util/utils';
|
||||
|
||||
// Only mock the network layer, not file operations
|
||||
vi.mock('../../../src/cache');
|
||||
|
||||
const { fetchWithCache } = await import('../../../src/cache');
|
||||
const mockFetchWithCache = vi.mocked(fetchWithCache);
|
||||
|
||||
describe('Azure Responses - Nested Schema Loading Integration', () => {
|
||||
const tempDir = path.join(__dirname, '.temp-nested-schema-test');
|
||||
let authHeadersValue: Record<string, string>;
|
||||
let restoreEnv: () => void;
|
||||
|
||||
beforeAll(() => {
|
||||
// Create temp directory and test files
|
||||
fs.mkdirSync(tempDir, { recursive: true });
|
||||
|
||||
// Create the nested schema file
|
||||
const nestedSchema = {
|
||||
type: 'object',
|
||||
properties: {
|
||||
event_name: { type: 'string' },
|
||||
date: { type: 'string' },
|
||||
location: { type: 'string' },
|
||||
},
|
||||
required: ['event_name', 'date', 'location'],
|
||||
additionalProperties: false,
|
||||
};
|
||||
fs.writeFileSync(
|
||||
path.join(tempDir, 'event-schema.json'),
|
||||
JSON.stringify(nestedSchema, null, 2),
|
||||
);
|
||||
|
||||
// Create the response_format file with nested file reference
|
||||
const responseFormat = {
|
||||
type: 'json_schema',
|
||||
name: 'event_extraction',
|
||||
schema: `file://${path.join(tempDir, 'event-schema.json')}`,
|
||||
};
|
||||
fs.writeFileSync(
|
||||
path.join(tempDir, 'nested-format.json'),
|
||||
JSON.stringify(responseFormat, null, 2),
|
||||
);
|
||||
|
||||
// Create a flat response_format file (no nested reference)
|
||||
const flatFormat = {
|
||||
type: 'json_schema',
|
||||
name: 'flat_schema',
|
||||
schema: {
|
||||
type: 'object',
|
||||
properties: { name: { type: 'string' } },
|
||||
required: ['name'],
|
||||
additionalProperties: false,
|
||||
},
|
||||
};
|
||||
fs.writeFileSync(path.join(tempDir, 'flat-format.json'), JSON.stringify(flatFormat, null, 2));
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
// Clean up temp directory
|
||||
fs.rmSync(tempDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
|
||||
restoreEnv = mockProcessEnv({
|
||||
AZURE_API_KEY: 'test-key',
|
||||
AZURE_API_HOST: 'test.openai.azure.com',
|
||||
});
|
||||
authHeadersValue = { 'api-key': 'test-key' };
|
||||
|
||||
// Mock the auth headers getter
|
||||
Object.defineProperty(AzureResponsesProvider.prototype, 'authHeaders', {
|
||||
get: vi.fn(() => authHeadersValue),
|
||||
configurable: true,
|
||||
});
|
||||
|
||||
// Mock ensureInitialized and getApiBaseUrl
|
||||
AzureResponsesProvider.prototype.ensureInitialized = vi.fn().mockResolvedValue(void 0);
|
||||
AzureResponsesProvider.prototype.getApiBaseUrl = vi
|
||||
.fn()
|
||||
.mockReturnValue('https://test.openai.azure.com');
|
||||
|
||||
// Mock successful API response
|
||||
mockFetchWithCache.mockResolvedValue({
|
||||
data: {
|
||||
output: [
|
||||
{
|
||||
type: 'message',
|
||||
role: 'assistant',
|
||||
content: [
|
||||
{
|
||||
type: 'output_text',
|
||||
text: '{"event_name": "Conference", "date": "2025-01-15", "location": "NYC"}',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
usage: { input_tokens: 10, output_tokens: 20 },
|
||||
},
|
||||
cached: false,
|
||||
status: 200,
|
||||
statusText: 'OK',
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
restoreEnv();
|
||||
});
|
||||
|
||||
it('should load nested schema from file reference (regression test for Azure bug)', async () => {
|
||||
const provider = new AzureResponsesProvider('gpt-4.1-test', {
|
||||
config: {
|
||||
response_format: `file://${path.join(tempDir, 'nested-format.json')}` as any,
|
||||
},
|
||||
});
|
||||
|
||||
// Call the API - this should successfully load both the outer format and nested schema
|
||||
const result = await provider.callApi('Extract event info from: Conference in NYC on Jan 15');
|
||||
|
||||
// Verify no error occurred (the bug would cause an error or malformed request)
|
||||
expect(result.error).toBeUndefined();
|
||||
expect(result.output).toBeDefined();
|
||||
|
||||
// Verify the API was called with the correct format structure
|
||||
expect(mockFetchWithCache).toHaveBeenCalled();
|
||||
const callArgs = mockFetchWithCache.mock.calls[0]!;
|
||||
const requestBody = JSON.parse(callArgs[1]!.body as string);
|
||||
|
||||
// The nested schema should be fully loaded (not a string file reference)
|
||||
expect(requestBody.text.format.type).toBe('json_schema');
|
||||
expect(requestBody.text.format.name).toBe('event_extraction');
|
||||
expect(requestBody.text.format.schema).toEqual({
|
||||
type: 'object',
|
||||
properties: {
|
||||
event_name: { type: 'string' },
|
||||
date: { type: 'string' },
|
||||
location: { type: 'string' },
|
||||
},
|
||||
required: ['event_name', 'date', 'location'],
|
||||
additionalProperties: false,
|
||||
});
|
||||
// Should NOT be a file reference string
|
||||
expect(typeof requestBody.text.format.schema).not.toBe('string');
|
||||
});
|
||||
|
||||
it('should handle flat response_format file without nested references', async () => {
|
||||
const provider = new AzureResponsesProvider('gpt-4.1-test', {
|
||||
config: {
|
||||
response_format: `file://${path.join(tempDir, 'flat-format.json')}` as any,
|
||||
},
|
||||
});
|
||||
|
||||
const result = await provider.callApi('Test prompt');
|
||||
|
||||
expect(result.error).toBeUndefined();
|
||||
|
||||
const callArgs = mockFetchWithCache.mock.calls[0]!;
|
||||
const requestBody = JSON.parse(callArgs[1]!.body as string);
|
||||
|
||||
expect(requestBody.text.format.type).toBe('json_schema');
|
||||
expect(requestBody.text.format.name).toBe('flat_schema');
|
||||
expect(requestBody.text.format.schema).toEqual({
|
||||
type: 'object',
|
||||
properties: { name: { type: 'string' } },
|
||||
required: ['name'],
|
||||
additionalProperties: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle inline response_format without file loading', async () => {
|
||||
const provider = new AzureResponsesProvider('gpt-4.1-test', {
|
||||
config: {
|
||||
// Using shorthand format (name/schema at top level) which the runtime code normalizes
|
||||
response_format: {
|
||||
type: 'json_schema',
|
||||
name: 'inline_test',
|
||||
schema: {
|
||||
type: 'object',
|
||||
properties: { result: { type: 'string' } },
|
||||
additionalProperties: false,
|
||||
},
|
||||
} as any,
|
||||
},
|
||||
});
|
||||
|
||||
const result = await provider.callApi('Test prompt');
|
||||
|
||||
expect(result.error).toBeUndefined();
|
||||
|
||||
const callArgs = mockFetchWithCache.mock.calls[0]!;
|
||||
const requestBody = JSON.parse(callArgs[1]!.body as string);
|
||||
|
||||
expect(requestBody.text.format.name).toBe('inline_test');
|
||||
expect(requestBody.text.format.schema).toEqual({
|
||||
type: 'object',
|
||||
properties: { result: { type: 'string' } },
|
||||
additionalProperties: false,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,626 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { fetchWithCache } from '../../../src/cache';
|
||||
import { AzureResponsesProvider } from '../../../src/providers/azure/responses';
|
||||
import { maybeLoadResponseFormatFromExternalFile } from '../../../src/util/file';
|
||||
import { mockProcessEnv } from '../../util/utils';
|
||||
import type { MockedFunction } from 'vitest';
|
||||
|
||||
// Mock external dependencies
|
||||
vi.mock('../../../src/cache');
|
||||
vi.mock('../../../src/util/file');
|
||||
|
||||
const mockFetchWithCache = fetchWithCache as MockedFunction<typeof fetchWithCache>;
|
||||
const mockMaybeLoadResponseFormatFromExternalFile =
|
||||
maybeLoadResponseFormatFromExternalFile as MockedFunction<
|
||||
typeof maybeLoadResponseFormatFromExternalFile
|
||||
>;
|
||||
let authHeadersValue: Record<string, string>;
|
||||
const originalOpenAiTemperature = process.env.OPENAI_TEMPERATURE;
|
||||
const originalOpenAiMaxTokens = process.env.OPENAI_MAX_TOKENS;
|
||||
const originalOpenAiMaxCompletionTokens = process.env.OPENAI_MAX_COMPLETION_TOKENS;
|
||||
|
||||
describe('AzureResponsesProvider', () => {
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
mockProcessEnv({ OPENAI_TEMPERATURE: undefined });
|
||||
mockProcessEnv({ OPENAI_MAX_TOKENS: undefined });
|
||||
mockProcessEnv({ OPENAI_MAX_COMPLETION_TOKENS: undefined });
|
||||
|
||||
// Mock environment variables
|
||||
mockProcessEnv({ AZURE_API_KEY: 'test-key' });
|
||||
mockProcessEnv({ AZURE_API_HOST: 'test.openai.azure.com' });
|
||||
authHeadersValue = { 'api-key': 'test-key' };
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
mockProcessEnv({ AZURE_API_KEY: undefined });
|
||||
mockProcessEnv({ AZURE_API_HOST: undefined });
|
||||
if (originalOpenAiTemperature === undefined) {
|
||||
mockProcessEnv({ OPENAI_TEMPERATURE: undefined });
|
||||
} else {
|
||||
mockProcessEnv({ OPENAI_TEMPERATURE: originalOpenAiTemperature });
|
||||
}
|
||||
if (originalOpenAiMaxTokens === undefined) {
|
||||
mockProcessEnv({ OPENAI_MAX_TOKENS: undefined });
|
||||
} else {
|
||||
mockProcessEnv({ OPENAI_MAX_TOKENS: originalOpenAiMaxTokens });
|
||||
}
|
||||
if (originalOpenAiMaxCompletionTokens === undefined) {
|
||||
mockProcessEnv({ OPENAI_MAX_COMPLETION_TOKENS: undefined });
|
||||
} else {
|
||||
mockProcessEnv({ OPENAI_MAX_COMPLETION_TOKENS: originalOpenAiMaxCompletionTokens });
|
||||
}
|
||||
delete (AzureResponsesProvider.prototype as any).authHeaders;
|
||||
});
|
||||
|
||||
describe('constructor', () => {
|
||||
it('should create an instance with deployment name', () => {
|
||||
const provider = new AzureResponsesProvider('gpt-4.1-test');
|
||||
expect(provider).toBeInstanceOf(AzureResponsesProvider);
|
||||
expect(provider.deploymentName).toBe('gpt-4.1-test');
|
||||
});
|
||||
});
|
||||
|
||||
describe('isReasoningModel', () => {
|
||||
it('should identify o1 models as reasoning models', () => {
|
||||
const provider = new AzureResponsesProvider('o1-preview');
|
||||
expect(provider.isReasoningModel()).toBe(true);
|
||||
});
|
||||
|
||||
it('should identify o3 models as reasoning models', () => {
|
||||
const provider = new AzureResponsesProvider('o3-mini');
|
||||
expect(provider.isReasoningModel()).toBe(true);
|
||||
});
|
||||
|
||||
it('should identify gpt-5 models as reasoning models', () => {
|
||||
const provider = new AzureResponsesProvider('gpt-5');
|
||||
expect(provider.isReasoningModel()).toBe(true);
|
||||
});
|
||||
|
||||
it('should not identify gpt-4.1 as reasoning model', () => {
|
||||
const provider = new AzureResponsesProvider('gpt-4.1');
|
||||
expect(provider.isReasoningModel()).toBe(false);
|
||||
});
|
||||
|
||||
it('should respect isReasoningModel config override for custom deployment names', () => {
|
||||
const provider = new AzureResponsesProvider('my-custom-deployment', {
|
||||
config: { isReasoningModel: true },
|
||||
});
|
||||
expect(provider.isReasoningModel()).toBe(true);
|
||||
});
|
||||
|
||||
it('should respect o1 config override for custom deployment names', () => {
|
||||
const provider = new AzureResponsesProvider('my-custom-deployment', {
|
||||
config: { o1: true },
|
||||
});
|
||||
expect(provider.isReasoningModel()).toBe(true);
|
||||
});
|
||||
|
||||
it('should not identify custom deployment as reasoning model without config override', () => {
|
||||
const provider = new AzureResponsesProvider('my-custom-deployment');
|
||||
expect(provider.isReasoningModel()).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('supportsTemperature', () => {
|
||||
it('should support temperature for non-reasoning models', () => {
|
||||
const provider = new AzureResponsesProvider('gpt-4.1');
|
||||
expect(provider.supportsTemperature()).toBe(true);
|
||||
});
|
||||
|
||||
it('should not support temperature for reasoning models', () => {
|
||||
const provider = new AzureResponsesProvider('o1-preview');
|
||||
expect(provider.supportsTemperature()).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getAzureResponsesBody', () => {
|
||||
it('should create correct request body for basic prompt', async () => {
|
||||
const provider = new AzureResponsesProvider('gpt-4.1-test');
|
||||
const body = await provider.getAzureResponsesBody('Hello world');
|
||||
|
||||
expect(body).toMatchObject({
|
||||
model: 'gpt-4.1-test',
|
||||
input: 'Hello world',
|
||||
text: {
|
||||
format: {
|
||||
type: 'text',
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle external response_format file loading (fixed double-loading bug)', async () => {
|
||||
const mockSchema = {
|
||||
type: 'json_schema',
|
||||
json_schema: {
|
||||
name: 'test_schema',
|
||||
schema: { type: 'object', properties: { test: { type: 'string' } } },
|
||||
},
|
||||
};
|
||||
|
||||
mockMaybeLoadResponseFormatFromExternalFile.mockReturnValue(mockSchema);
|
||||
|
||||
const provider = new AzureResponsesProvider('gpt-4.1-test', {
|
||||
config: {
|
||||
response_format: 'file://test-schema.json' as any,
|
||||
},
|
||||
});
|
||||
|
||||
const body = await provider.getAzureResponsesBody('Hello world');
|
||||
|
||||
expect(mockMaybeLoadResponseFormatFromExternalFile).toHaveBeenCalledWith(
|
||||
'file://test-schema.json',
|
||||
undefined,
|
||||
);
|
||||
expect(mockMaybeLoadResponseFormatFromExternalFile).toHaveBeenCalledTimes(1); // Should only be called once (fix for double-loading)
|
||||
expect(body.text.format).toMatchObject({
|
||||
type: 'json_schema',
|
||||
name: 'test_schema',
|
||||
schema: mockSchema.json_schema.schema,
|
||||
strict: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle inline response_format', async () => {
|
||||
// For inline schemas, maybeLoadResponseFormatFromExternalFile should return the object unchanged
|
||||
mockMaybeLoadResponseFormatFromExternalFile.mockImplementation(function (input: any) {
|
||||
return input;
|
||||
});
|
||||
|
||||
const provider = new AzureResponsesProvider('gpt-4.1-test', {
|
||||
config: {
|
||||
response_format: {
|
||||
type: 'json_schema',
|
||||
json_schema: {
|
||||
name: 'inline_schema',
|
||||
strict: true,
|
||||
schema: {
|
||||
type: 'object',
|
||||
properties: { result: { type: 'string' } },
|
||||
additionalProperties: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const body = await provider.getAzureResponsesBody('Hello world');
|
||||
|
||||
expect(body.text.format).toMatchObject({
|
||||
type: 'json_schema',
|
||||
name: 'inline_schema',
|
||||
schema: {
|
||||
type: 'object',
|
||||
properties: { result: { type: 'string' } },
|
||||
additionalProperties: false,
|
||||
},
|
||||
strict: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('should not include temperature for reasoning models', async () => {
|
||||
const provider = new AzureResponsesProvider('o1-preview', {
|
||||
config: { temperature: 0.7 },
|
||||
});
|
||||
|
||||
const body = await provider.getAzureResponsesBody('Hello world');
|
||||
|
||||
expect(body).not.toHaveProperty('temperature');
|
||||
});
|
||||
|
||||
it('should include temperature for non-reasoning models', async () => {
|
||||
const provider = new AzureResponsesProvider('gpt-4.1-test', {
|
||||
config: { temperature: 0.7 },
|
||||
});
|
||||
|
||||
const body = await provider.getAzureResponsesBody('Hello world');
|
||||
|
||||
expect(body.temperature).toBe(0.7);
|
||||
});
|
||||
|
||||
it('should correctly send temperature: 0 in the request body', async () => {
|
||||
// Test that temperature: 0 is correctly sent (not filtered out by falsy check)
|
||||
const provider = new AzureResponsesProvider('gpt-4.1-test', {
|
||||
config: { temperature: 0 },
|
||||
});
|
||||
|
||||
const body = await provider.getAzureResponsesBody('Hello world');
|
||||
|
||||
// temperature: 0 should be present in the request body
|
||||
expect(body.temperature).toBe(0);
|
||||
expect('temperature' in body).toBe(true);
|
||||
});
|
||||
|
||||
it('should omit default temperature and max_output_tokens when omitDefaults is true', async () => {
|
||||
const provider = new AzureResponsesProvider('gpt-4.1-test', {
|
||||
config: { omitDefaults: true },
|
||||
});
|
||||
|
||||
const body = await provider.getAzureResponsesBody('Hello world');
|
||||
|
||||
expect(body.temperature).toBeUndefined();
|
||||
expect('temperature' in body).toBe(false);
|
||||
expect(body.max_output_tokens).toBeUndefined();
|
||||
expect('max_output_tokens' in body).toBe(false);
|
||||
});
|
||||
|
||||
it('should use env defaults with omitDefaults when OPENAI env vars are set', async () => {
|
||||
mockProcessEnv({ OPENAI_TEMPERATURE: '0.5' });
|
||||
mockProcessEnv({ OPENAI_MAX_TOKENS: '2048' });
|
||||
|
||||
const provider = new AzureResponsesProvider('gpt-4.1-test', {
|
||||
config: { omitDefaults: true },
|
||||
});
|
||||
|
||||
const body = await provider.getAzureResponsesBody('Hello world');
|
||||
|
||||
expect(body.temperature).toBe(0.5);
|
||||
expect('temperature' in body).toBe(true);
|
||||
expect(body.max_output_tokens).toBe(2048);
|
||||
expect('max_output_tokens' in body).toBe(true);
|
||||
});
|
||||
|
||||
it('should prefer OPENAI_MAX_COMPLETION_TOKENS over OPENAI_MAX_TOKENS for reasoning models', async () => {
|
||||
mockProcessEnv({ OPENAI_MAX_COMPLETION_TOKENS: '4096' });
|
||||
mockProcessEnv({ OPENAI_MAX_TOKENS: '2048' });
|
||||
|
||||
const provider = new AzureResponsesProvider('o1-preview', {
|
||||
config: { omitDefaults: true },
|
||||
});
|
||||
|
||||
const body = await provider.getAzureResponsesBody('Hello world');
|
||||
|
||||
expect(body.max_output_tokens).toBe(4096);
|
||||
expect('max_output_tokens' in body).toBe(true);
|
||||
});
|
||||
|
||||
it('should fall back to OPENAI_MAX_TOKENS for reasoning models when OPENAI_MAX_COMPLETION_TOKENS is unset', async () => {
|
||||
mockProcessEnv({ OPENAI_MAX_TOKENS: '2048' });
|
||||
|
||||
const provider = new AzureResponsesProvider('o1-preview', {
|
||||
config: { omitDefaults: true },
|
||||
});
|
||||
|
||||
const body = await provider.getAzureResponsesBody('Hello world');
|
||||
|
||||
expect(body.max_output_tokens).toBe(2048);
|
||||
expect('max_output_tokens' in body).toBe(true);
|
||||
});
|
||||
|
||||
it('should use OPENAI_MAX_TOKENS for reasoning models when omitDefaults is false and OPENAI_MAX_COMPLETION_TOKENS is unset', async () => {
|
||||
mockProcessEnv({ OPENAI_MAX_TOKENS: '2048' });
|
||||
|
||||
const provider = new AzureResponsesProvider('o1-preview');
|
||||
|
||||
const body = await provider.getAzureResponsesBody('Hello world');
|
||||
|
||||
expect(body.max_output_tokens).toBe(2048);
|
||||
expect('max_output_tokens' in body).toBe(true);
|
||||
});
|
||||
|
||||
it('should not apply a hardcoded max_output_tokens default for reasoning models when omitDefaults is false', async () => {
|
||||
const provider = new AzureResponsesProvider('o1-preview');
|
||||
|
||||
const body = await provider.getAzureResponsesBody('Hello world');
|
||||
|
||||
expect(body.max_output_tokens).toBeUndefined();
|
||||
expect('max_output_tokens' in body).toBe(false);
|
||||
});
|
||||
|
||||
it('should omit default max_output_tokens when omitDefaults is true for reasoning models', async () => {
|
||||
const provider = new AzureResponsesProvider('o1-preview', {
|
||||
config: { omitDefaults: true },
|
||||
});
|
||||
|
||||
const body = await provider.getAzureResponsesBody('Hello world');
|
||||
|
||||
expect(body.max_output_tokens).toBeUndefined();
|
||||
expect('max_output_tokens' in body).toBe(false);
|
||||
});
|
||||
|
||||
it('should correctly send max_output_tokens: 0 in the request body when explicitly set', async () => {
|
||||
// Test that max_output_tokens: 0 is correctly sent (not filtered out by falsy check)
|
||||
// Note: While max_output_tokens: 0 is impractical, it should still be sent if explicitly configured
|
||||
const provider = new AzureResponsesProvider('gpt-4.1-test', {
|
||||
config: { max_output_tokens: 0 } as any,
|
||||
});
|
||||
|
||||
const body = await provider.getAzureResponsesBody('Hello world');
|
||||
|
||||
// max_output_tokens: 0 should be present in the request body
|
||||
expect(body.max_output_tokens).toBe(0);
|
||||
expect('max_output_tokens' in body).toBe(true);
|
||||
});
|
||||
|
||||
it('should include verbosity for reasoning models when configured', async () => {
|
||||
const provider = new AzureResponsesProvider('gpt-5', {
|
||||
config: { verbosity: 'high' },
|
||||
});
|
||||
|
||||
const body = await provider.getAzureResponsesBody('Hello world');
|
||||
|
||||
expect(body.text).toMatchObject({
|
||||
format: { type: 'text' },
|
||||
verbosity: 'high',
|
||||
});
|
||||
});
|
||||
|
||||
it('should include verbosity for custom deployment with isReasoningModel override', async () => {
|
||||
const provider = new AzureResponsesProvider('my-custom-deployment', {
|
||||
config: { isReasoningModel: true, verbosity: 'medium' },
|
||||
});
|
||||
|
||||
const body = await provider.getAzureResponsesBody('Hello world');
|
||||
|
||||
expect(body.text).toMatchObject({
|
||||
format: { type: 'text' },
|
||||
verbosity: 'medium',
|
||||
});
|
||||
});
|
||||
|
||||
it('should not include verbosity for non-reasoning models', async () => {
|
||||
const provider = new AzureResponsesProvider('gpt-4.1-test', {
|
||||
config: { verbosity: 'high' },
|
||||
});
|
||||
|
||||
const body = await provider.getAzureResponsesBody('Hello world');
|
||||
|
||||
expect(body.text).toMatchObject({
|
||||
format: { type: 'text' },
|
||||
});
|
||||
expect(body.text.verbosity).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('callApi', () => {
|
||||
beforeEach(() => {
|
||||
// Mock the generic provider's method for getting auth headers and URL
|
||||
AzureResponsesProvider.prototype.ensureInitialized = vi.fn().mockResolvedValue(void 0);
|
||||
AzureResponsesProvider.prototype.getApiBaseUrl = vi
|
||||
.fn()
|
||||
.mockReturnValue('https://test.openai.azure.com');
|
||||
Object.defineProperty(AzureResponsesProvider.prototype, 'authHeaders', {
|
||||
get: vi.fn(() => authHeadersValue),
|
||||
set: vi.fn((value: Record<string, string>) => {
|
||||
authHeadersValue = value;
|
||||
}),
|
||||
configurable: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('should provide clear error for missing API host', async () => {
|
||||
const provider = new AzureResponsesProvider('gpt-4.1-test');
|
||||
vi.spyOn(provider, 'getApiBaseUrl').mockReturnValue('');
|
||||
|
||||
await expect(provider.callApi('test')).rejects.toThrow(
|
||||
/Azure API configuration missing.*AZURE_API_HOST/,
|
||||
);
|
||||
});
|
||||
|
||||
it('should provide clear error for missing authentication', async () => {
|
||||
const provider = new AzureResponsesProvider('gpt-4.1-test');
|
||||
|
||||
// Mock initialization to set empty auth headers
|
||||
vi.spyOn(provider, 'ensureInitialized').mockImplementation(async function () {
|
||||
(provider as any).authHeaders = {}; // Set empty auth headers
|
||||
});
|
||||
|
||||
await expect(provider.callApi('test')).rejects.toThrow(
|
||||
/Azure API authentication failed.*AZURE_API_KEY/,
|
||||
);
|
||||
});
|
||||
|
||||
it('should accept Entra ID authentication with Authorization header', async () => {
|
||||
const mockResponse = {
|
||||
output: [
|
||||
{
|
||||
type: 'message',
|
||||
role: 'assistant',
|
||||
content: [
|
||||
{
|
||||
type: 'output_text',
|
||||
text: 'Hello from Entra ID auth!',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
usage: {
|
||||
input_tokens: 10,
|
||||
output_tokens: 8,
|
||||
},
|
||||
};
|
||||
|
||||
mockFetchWithCache.mockResolvedValue({
|
||||
data: mockResponse,
|
||||
cached: false,
|
||||
status: 200,
|
||||
statusText: 'OK',
|
||||
});
|
||||
|
||||
const provider = new AzureResponsesProvider('gpt-4.1-test');
|
||||
|
||||
// Mock ensureInitialized to set authHeaders with Authorization (Entra ID)
|
||||
vi.spyOn(provider, 'ensureInitialized').mockImplementation(async function () {
|
||||
(provider as any).authHeaders = { Authorization: 'Bearer test-entra-token' };
|
||||
});
|
||||
|
||||
const result = await provider.callApi('Hello');
|
||||
|
||||
// Verify the call succeeded (no error about missing authentication)
|
||||
expect(result).toMatchObject({
|
||||
output: 'Hello from Entra ID auth!',
|
||||
cached: false,
|
||||
});
|
||||
|
||||
// Verify the API was called (auth check passed)
|
||||
expect(mockFetchWithCache).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('reports non-zero cost from the Responses usage object (regression)', async () => {
|
||||
// Regression for the costCalculator that passed `usage` into calculateAzureCost's ignored
|
||||
// config slot (never forwarding token counts), so every Azure Responses eval reported cost 0.
|
||||
const mockResponse = {
|
||||
output: [
|
||||
{ type: 'message', role: 'assistant', content: [{ type: 'output_text', text: '4' }] },
|
||||
],
|
||||
usage: { input_tokens: 1000, output_tokens: 500 },
|
||||
};
|
||||
mockFetchWithCache.mockResolvedValue({
|
||||
data: mockResponse,
|
||||
cached: false,
|
||||
status: 200,
|
||||
statusText: 'OK',
|
||||
});
|
||||
|
||||
const provider = new AzureResponsesProvider('gpt-4.1');
|
||||
vi.spyOn(provider, 'ensureInitialized').mockImplementation(async function () {
|
||||
(provider as any).authHeaders = { 'api-key': 'test-key' };
|
||||
});
|
||||
|
||||
const result = await provider.callApi('What is 2+2?');
|
||||
|
||||
// gpt-4.1 is priced in AZURE_MODELS, and tokens now flow through, so cost must be > 0.
|
||||
expect(result.cost).toBeGreaterThan(0);
|
||||
expect(result.tokenUsage).toMatchObject({ prompt: 1000, completion: 500 });
|
||||
});
|
||||
|
||||
it('should validate external response_format files', async () => {
|
||||
const provider = new AzureResponsesProvider('gpt-4.1-test', {
|
||||
config: { response_format: 'file://missing.json' as any },
|
||||
});
|
||||
|
||||
mockMaybeLoadResponseFormatFromExternalFile.mockImplementation(function () {
|
||||
throw new Error('File does not exist');
|
||||
});
|
||||
|
||||
await expect(provider.callApi('test')).rejects.toThrow(
|
||||
/Failed to load response_format file.*missing\.json/,
|
||||
);
|
||||
});
|
||||
|
||||
it('should make successful API call', async () => {
|
||||
const mockResponse = {
|
||||
output: [
|
||||
{
|
||||
type: 'message',
|
||||
role: 'assistant',
|
||||
content: [
|
||||
{
|
||||
type: 'output_text',
|
||||
text: 'Hello! How can I help you?',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
usage: {
|
||||
input_tokens: 10,
|
||||
output_tokens: 8,
|
||||
},
|
||||
};
|
||||
|
||||
mockFetchWithCache.mockResolvedValue({
|
||||
data: mockResponse,
|
||||
cached: false,
|
||||
status: 200,
|
||||
statusText: 'OK',
|
||||
});
|
||||
|
||||
const provider = new AzureResponsesProvider('gpt-4.1-test');
|
||||
const result = await provider.callApi('Hello');
|
||||
|
||||
expect(result).toMatchObject({
|
||||
output: 'Hello! How can I help you?',
|
||||
cached: false,
|
||||
});
|
||||
|
||||
expect(mockFetchWithCache).toHaveBeenCalledWith(
|
||||
'https://test.openai.azure.com/openai/v1/responses?api-version=preview',
|
||||
expect.objectContaining({
|
||||
method: 'POST',
|
||||
headers: expect.objectContaining({
|
||||
'Content-Type': 'application/json',
|
||||
'api-key': 'test-key',
|
||||
}),
|
||||
body: expect.stringContaining('gpt-4.1-test'),
|
||||
}),
|
||||
expect.any(Number),
|
||||
'json',
|
||||
undefined,
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle API errors', async () => {
|
||||
mockFetchWithCache.mockResolvedValue({
|
||||
data: { error: { message: 'Invalid API key' } },
|
||||
cached: false,
|
||||
status: 401,
|
||||
statusText: 'Unauthorized',
|
||||
});
|
||||
|
||||
const provider = new AzureResponsesProvider('gpt-4.1-test');
|
||||
const result = await provider.callApi('Hello');
|
||||
|
||||
expect(result.error).toContain('API error: 401');
|
||||
});
|
||||
|
||||
it('should handle deep research model timeout', async () => {
|
||||
const provider = new AzureResponsesProvider('o3-deep-research-test');
|
||||
|
||||
mockFetchWithCache.mockResolvedValue({
|
||||
data: {
|
||||
output: [
|
||||
{
|
||||
type: 'message',
|
||||
role: 'assistant',
|
||||
content: [{ type: 'output_text', text: 'Research complete' }],
|
||||
},
|
||||
],
|
||||
},
|
||||
cached: false,
|
||||
status: 200,
|
||||
statusText: 'OK',
|
||||
});
|
||||
|
||||
await provider.callApi('Research question');
|
||||
|
||||
expect(mockFetchWithCache).toHaveBeenCalledWith(
|
||||
expect.any(String),
|
||||
expect.any(Object),
|
||||
600000, // 10 minutes timeout for deep research
|
||||
'json',
|
||||
undefined,
|
||||
);
|
||||
});
|
||||
|
||||
it('should construct correct Azure URL format', async () => {
|
||||
const provider = new AzureResponsesProvider('gpt-4.1-test');
|
||||
|
||||
mockFetchWithCache.mockResolvedValue({
|
||||
data: {
|
||||
output: [
|
||||
{
|
||||
type: 'message',
|
||||
role: 'assistant',
|
||||
content: [{ type: 'output_text', text: 'response' }],
|
||||
},
|
||||
],
|
||||
},
|
||||
cached: false,
|
||||
status: 200,
|
||||
statusText: 'OK',
|
||||
});
|
||||
|
||||
await provider.callApi('Hello');
|
||||
|
||||
// Verify the URL does NOT include deployment name (Azure Responses API pattern)
|
||||
expect(mockFetchWithCache).toHaveBeenCalledWith(
|
||||
'https://test.openai.azure.com/openai/v1/responses?api-version=preview',
|
||||
expect.any(Object),
|
||||
expect.any(Number),
|
||||
'json',
|
||||
undefined,
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,320 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { AZURE_MODELS } from '../../../src/providers/azure/defaults';
|
||||
import { calculateAzureCost, throwConfigurationError } from '../../../src/providers/azure/util';
|
||||
|
||||
describe('throwConfigurationError', () => {
|
||||
it('throws error with formatted message and docs link', () => {
|
||||
const message = 'Test error message';
|
||||
expect(() => throwConfigurationError(message)).toThrow(
|
||||
`${message}\n\nSee https://www.promptfoo.dev/docs/providers/azure/ to learn more about Azure configuration.`,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('calculateAzureCost', () => {
|
||||
it('calculates cost for valid model and tokens', () => {
|
||||
const cost = calculateAzureCost(
|
||||
'gpt-5.4',
|
||||
{},
|
||||
100, // prompt tokens
|
||||
50, // completion tokens
|
||||
);
|
||||
expect(cost).toBeDefined();
|
||||
expect(typeof cost).toBe('number');
|
||||
});
|
||||
|
||||
it('calculates cost for dated GPT-5.4 mini snapshots', () => {
|
||||
const cost = calculateAzureCost('gpt-5.4-mini-2026-03-17', {}, 100, 50);
|
||||
expect(cost).toBeDefined();
|
||||
expect(typeof cost).toBe('number');
|
||||
});
|
||||
|
||||
it('calculates cost for dated GPT-5.4 nano snapshots', () => {
|
||||
const cost = calculateAzureCost('gpt-5.4-nano-2026-03-17', {}, 100, 50);
|
||||
expect(cost).toBeDefined();
|
||||
expect(typeof cost).toBe('number');
|
||||
});
|
||||
|
||||
it('uses long-context pricing for GPT-5.4 above the 272k threshold', () => {
|
||||
expect(calculateAzureCost('gpt-5.4', {}, 272_000, 1_000)).toBeCloseTo(
|
||||
(272_000 * 2.5 + 1_000 * 15) / 1e6,
|
||||
12,
|
||||
);
|
||||
expect(calculateAzureCost('gpt-5.4', {}, 272_001, 1_000)).toBeCloseTo(
|
||||
(272_001 * 5 + 1_000 * 22.5) / 1e6,
|
||||
12,
|
||||
);
|
||||
});
|
||||
|
||||
it('uses long-context pricing for GPT-5.4 pro above the 272k threshold', () => {
|
||||
expect(calculateAzureCost('gpt-5.4-pro', {}, 272_000, 1_000)).toBeCloseTo(
|
||||
(272_000 * 30 + 1_000 * 180) / 1e6,
|
||||
12,
|
||||
);
|
||||
expect(calculateAzureCost('gpt-5.4-pro', {}, 272_001, 1_000)).toBeCloseTo(
|
||||
(272_001 * 60 + 1_000 * 270) / 1e6,
|
||||
12,
|
||||
);
|
||||
});
|
||||
|
||||
it('uses long-context pricing for GPT-5.5 above the 272k threshold', () => {
|
||||
expect(calculateAzureCost('gpt-5.5', {}, 272_000, 1_000)).toBeCloseTo(
|
||||
(272_000 * 5 + 1_000 * 30) / 1e6,
|
||||
12,
|
||||
);
|
||||
expect(calculateAzureCost('gpt-5.5', {}, 272_001, 1_000)).toBeCloseTo(
|
||||
(272_001 * 10 + 1_000 * 45) / 1e6,
|
||||
12,
|
||||
);
|
||||
});
|
||||
|
||||
it('calculates cost for Claude Fable 5', () => {
|
||||
expect(calculateAzureCost('claude-fable-5', {}, 1000, 500)).toBeCloseTo(0.035, 6);
|
||||
});
|
||||
|
||||
it('returns undefined for unknown model', () => {
|
||||
const cost = calculateAzureCost('unknown-model', {}, 100, 50);
|
||||
expect(cost).toBeUndefined();
|
||||
});
|
||||
|
||||
it('calculates cost for Microsoft MAI image models from output tokens', () => {
|
||||
// MAI-Image-2.5 bills image output at $33/1M tokens; input is unused here.
|
||||
const cost = calculateAzureCost('MAI-Image-2.5', {}, 0, 1000);
|
||||
expect(cost).toBeCloseTo(0.033, 6);
|
||||
});
|
||||
|
||||
it('calculates cost for the MAI-DS-R1 reasoning model', () => {
|
||||
const cost = calculateAzureCost('MAI-DS-R1', {}, 100, 50);
|
||||
expect(cost).toBeDefined();
|
||||
expect(typeof cost).toBe('number');
|
||||
});
|
||||
|
||||
it('returns undefined when tokens are undefined', () => {
|
||||
const cost = calculateAzureCost('gpt-4', {}, undefined, undefined);
|
||||
expect(cost).toBeUndefined();
|
||||
});
|
||||
|
||||
it('returns zero cost with zero tokens', () => {
|
||||
const cost = calculateAzureCost('gpt-4', {}, 0, 0);
|
||||
expect(cost).toBe(0);
|
||||
});
|
||||
|
||||
it('handles empty config object', () => {
|
||||
const cost = calculateAzureCost('gpt-4', {}, 100, 50);
|
||||
expect(cost).toBeDefined();
|
||||
expect(typeof cost).toBe('number');
|
||||
});
|
||||
|
||||
it('handles undefined completion tokens', () => {
|
||||
const cost = calculateAzureCost('gpt-4', {}, 100, undefined);
|
||||
expect(cost).toBeUndefined();
|
||||
});
|
||||
|
||||
it('handles undefined prompt tokens', () => {
|
||||
const cost = calculateAzureCost('gpt-4', {}, undefined, 50);
|
||||
expect(cost).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('AZURE_MODELS cost coverage', () => {
|
||||
// Guards against the cost=0 / undefined-cost class: every supported model in the pricing
|
||||
// table must compute a positive, finite cost for non-zero token usage.
|
||||
it('every AZURE_MODELS entry computes a positive, finite cost', () => {
|
||||
const broken: string[] = [];
|
||||
for (const { id } of AZURE_MODELS) {
|
||||
const cost = calculateAzureCost(id, {}, 1000, 1000);
|
||||
if (typeof cost !== 'number' || !Number.isFinite(cost) || cost <= 0) {
|
||||
broken.push(`${id} => ${cost}`);
|
||||
}
|
||||
}
|
||||
expect(broken).toEqual([]);
|
||||
});
|
||||
|
||||
it('has no duplicate model ids', () => {
|
||||
const ids = AZURE_MODELS.map((m) => m.id);
|
||||
const dupes = ids.filter((id, i) => ids.indexOf(id) !== i);
|
||||
expect(dupes).toEqual([]);
|
||||
});
|
||||
|
||||
it.each([
|
||||
{
|
||||
id: 'gpt-5.4',
|
||||
inputTokens: 1000,
|
||||
outputTokens: 1000,
|
||||
expectedCost: 0.0175, // $2.50/1M input + $15/1M output
|
||||
family: 'GPT-5.x',
|
||||
},
|
||||
{ id: 'gpt-4.1', inputTokens: 1000, outputTokens: 1000, expectedCost: 0.01, family: 'GPT-4.1' },
|
||||
{ id: 'gpt-4o', inputTokens: 1000, outputTokens: 1000, expectedCost: 0.0125, family: 'GPT-4o' },
|
||||
{
|
||||
id: 'o3',
|
||||
inputTokens: 1000,
|
||||
outputTokens: 1000,
|
||||
expectedCost: 0.01, // $2/1M input + $8/1M output (June 2025 price cut)
|
||||
family: 'o-series reasoning',
|
||||
},
|
||||
{
|
||||
id: 'text-embedding-3-large',
|
||||
inputTokens: 1000,
|
||||
outputTokens: 0,
|
||||
expectedCost: 0.00013,
|
||||
family: 'embeddings',
|
||||
},
|
||||
{
|
||||
id: 'gpt-image-1',
|
||||
inputTokens: 1000,
|
||||
outputTokens: 1000,
|
||||
expectedCost: 0.045,
|
||||
family: 'image',
|
||||
},
|
||||
{
|
||||
id: 'claude-opus-4-7',
|
||||
inputTokens: 1000,
|
||||
outputTokens: 1000,
|
||||
expectedCost: 0.03,
|
||||
family: 'Anthropic Claude',
|
||||
},
|
||||
{
|
||||
id: 'Llama-3.3-70B-Instruct',
|
||||
inputTokens: 1000,
|
||||
outputTokens: 1000,
|
||||
expectedCost: 0.00074,
|
||||
family: 'Meta Llama',
|
||||
},
|
||||
{
|
||||
id: 'DeepSeek-R1',
|
||||
inputTokens: 1000,
|
||||
outputTokens: 1000,
|
||||
expectedCost: 0.00274,
|
||||
family: 'DeepSeek',
|
||||
},
|
||||
{
|
||||
id: 'grok-4',
|
||||
inputTokens: 1000,
|
||||
outputTokens: 1000,
|
||||
expectedCost: 0.018,
|
||||
family: 'xAI Grok',
|
||||
},
|
||||
{
|
||||
id: 'Kimi-K2-Thinking',
|
||||
inputTokens: 1000,
|
||||
outputTokens: 1000,
|
||||
expectedCost: 0.0031,
|
||||
family: 'MoonshotAI Kimi',
|
||||
},
|
||||
{
|
||||
id: 'Phi-4',
|
||||
inputTokens: 1000,
|
||||
outputTokens: 1000,
|
||||
expectedCost: 0.00021,
|
||||
family: 'Microsoft Phi',
|
||||
},
|
||||
{
|
||||
id: 'Mistral-Large-3',
|
||||
inputTokens: 1000,
|
||||
outputTokens: 1000,
|
||||
expectedCost: 0.002,
|
||||
family: 'Mistral',
|
||||
},
|
||||
{
|
||||
id: 'Cohere-command-r',
|
||||
inputTokens: 1000,
|
||||
outputTokens: 1000,
|
||||
expectedCost: 0.00075,
|
||||
family: 'Cohere',
|
||||
},
|
||||
{
|
||||
id: 'AI21-Jamba-1.5-Large',
|
||||
inputTokens: 1000,
|
||||
outputTokens: 1000,
|
||||
expectedCost: 0.001,
|
||||
family: 'AI21',
|
||||
},
|
||||
{
|
||||
id: 'MAI-DS-R1',
|
||||
inputTokens: 1000,
|
||||
outputTokens: 1000,
|
||||
expectedCost: 0.00274,
|
||||
family: 'Microsoft MAI',
|
||||
},
|
||||
])('computes expected representative cost for $family ($id)', ({
|
||||
id,
|
||||
inputTokens,
|
||||
outputTokens,
|
||||
expectedCost,
|
||||
}) => {
|
||||
expect(calculateAzureCost(id, {}, inputTokens, outputTokens)).toBeCloseTo(expectedCost, 12);
|
||||
});
|
||||
|
||||
// Spot-check that a representative of each supported family is priced (documents coverage and
|
||||
// fails loudly if a whole family is dropped from the table).
|
||||
it.each([
|
||||
['gpt-5.4', 'GPT-5.x'],
|
||||
['gpt-4.1', 'GPT-4.1'],
|
||||
['gpt-4o', 'GPT-4o'],
|
||||
['o3', 'o-series reasoning'],
|
||||
['gpt-4o-mini-transcribe', 'audio/transcribe'],
|
||||
['text-embedding-3-large', 'embeddings'],
|
||||
['gpt-image-1', 'image'],
|
||||
['claude-opus-4-7', 'Anthropic Claude'],
|
||||
['Llama-3.3-70B-Instruct', 'Meta Llama'],
|
||||
['DeepSeek-R1', 'DeepSeek'],
|
||||
['DeepSeek-V4-Pro', 'DeepSeek V4'],
|
||||
['grok-4', 'xAI Grok'],
|
||||
['grok-4.3', 'xAI Grok 4.3'],
|
||||
['Kimi-K2-Thinking', 'MoonshotAI Kimi'],
|
||||
['gpt-oss-120b', 'OpenAI open-weight'],
|
||||
['gpt-5.1-codex-max', 'GPT-5.1 Codex Max'],
|
||||
['Phi-4', 'Microsoft Phi'],
|
||||
['Phi-3-mini-4k-instruct', 'Phi-3 4K/8K variants'],
|
||||
['Mistral-Large-3', 'Mistral'],
|
||||
['Cohere-command-r', 'Cohere'],
|
||||
['AI21-Jamba-1.5-Large', 'AI21'],
|
||||
['MAI-DS-R1', 'Microsoft MAI'],
|
||||
])('prices the %s family (%s)', (id) => {
|
||||
expect(calculateAzureCost(id, {}, 1000, 1000)).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
// Exact standard-tier per-1M input/output rates for entries added/corrected from official
|
||||
// pricing sources. Probing input and output separately catches a swapped input/output or a
|
||||
// transcription typo. Input is probed at 100k tokens — below every long-context threshold —
|
||||
// so tiered models (gpt-5.4-pro, gpt-5.5) assert their standard input rate here, while the
|
||||
// long-context tiers are pinned by the dedicated boundary tests above.
|
||||
it.each([
|
||||
['gpt-5.5', 5, 30],
|
||||
['gpt-5.2', 1.75, 14],
|
||||
['gpt-5.1-codex-max', 1.25, 10],
|
||||
['gpt-5', 1.25, 10],
|
||||
['gpt-5-pro', 15, 120],
|
||||
['gpt-5-mini', 0.25, 2],
|
||||
['gpt-5-nano', 0.05, 0.4],
|
||||
['gpt-5-chat', 1.25, 10],
|
||||
['gpt-5-codex', 1.25, 10],
|
||||
['gpt-5.1', 1.25, 10],
|
||||
['gpt-5.1-chat', 1.25, 10],
|
||||
['gpt-5.1-codex', 1.25, 10],
|
||||
['gpt-5.1-codex-mini', 0.25, 2],
|
||||
['gpt-5.4-pro', 30, 180],
|
||||
['gpt-5.4-mini', 0.75, 4.5],
|
||||
['gpt-5.4-nano', 0.2, 1.25],
|
||||
['claude-haiku-4-5', 1, 5],
|
||||
['claude-haiku-4-5-20251001', 1, 5],
|
||||
['o3', 2, 8],
|
||||
['gpt-realtime', 4, 16],
|
||||
['gpt-audio-mini', 0.6, 2.4],
|
||||
['gpt-4o-mini-transcribe', 1.25, 5],
|
||||
['gpt-4o-mini-tts', 0.6, 12],
|
||||
['grok-code-fast-1', 0.2, 1.5],
|
||||
['grok-4.3', 1.25, 2.5],
|
||||
['grok-4-1-fast-reasoning', 0.2, 0.5],
|
||||
['Kimi-K2-Thinking', 0.6, 2.5],
|
||||
['Kimi-K2.6', 0.95, 4],
|
||||
['DeepSeek-V3.2', 0.58, 1.68],
|
||||
['DeepSeek-V4-Pro', 1.74, 3.48],
|
||||
['gpt-oss-120b', 0.15, 0.6],
|
||||
['Phi-3-medium-4k-instruct', 0.17, 0.68],
|
||||
])('prices %s at exactly %d in / %d out per 1M', (id, inputPerM, outputPerM) => {
|
||||
expect(calculateAzureCost(id, {}, 100_000, 0)).toBeCloseTo((inputPerM as number) / 10, 9);
|
||||
expect(calculateAzureCost(id, {}, 0, 1_000_000)).toBeCloseTo(outputPerM as number, 9);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,611 @@
|
||||
import fsPromises from 'fs/promises';
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import {
|
||||
AZURE_SORA_COST_PER_SECOND,
|
||||
AZURE_VIDEO_DIMENSIONS,
|
||||
AZURE_VIDEO_DURATIONS,
|
||||
} from '../../../src/providers/azure/defaults';
|
||||
import {
|
||||
AzureVideoProvider,
|
||||
calculateAzureVideoCost,
|
||||
validateAzureVideoDimensions,
|
||||
validateAzureVideoDuration,
|
||||
} from '../../../src/providers/azure/video';
|
||||
import { generateVideoCacheKey } from '../../../src/providers/video';
|
||||
|
||||
// Hoist mock functions so they're available in vi.mock factories
|
||||
const {
|
||||
mockFetchWithProxy,
|
||||
mockStoreMedia,
|
||||
mockMediaExists,
|
||||
mockGetMediaStorage,
|
||||
mockGetConfigDirectoryPath,
|
||||
} = vi.hoisted(() => ({
|
||||
mockFetchWithProxy: vi.fn(),
|
||||
mockStoreMedia: vi.fn(),
|
||||
mockMediaExists: vi.fn(),
|
||||
mockGetMediaStorage: vi.fn(),
|
||||
mockGetConfigDirectoryPath: vi.fn(),
|
||||
}));
|
||||
|
||||
const fsPromiseMocks = vi.hoisted(() => ({
|
||||
mkdir: vi.fn(),
|
||||
readFile: vi.fn(),
|
||||
writeFile: vi.fn(),
|
||||
}));
|
||||
|
||||
// Mock the dependencies
|
||||
vi.mock('fs/promises', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('fs/promises')>();
|
||||
return {
|
||||
...actual,
|
||||
default: {
|
||||
...actual,
|
||||
...fsPromiseMocks,
|
||||
},
|
||||
...fsPromiseMocks,
|
||||
};
|
||||
});
|
||||
vi.mock('../../../src/storage', () => ({
|
||||
storeMedia: mockStoreMedia,
|
||||
mediaExists: mockMediaExists,
|
||||
getMediaStorage: mockGetMediaStorage,
|
||||
}));
|
||||
vi.mock('../../../src/util/config/manage', () => ({
|
||||
getConfigDirectoryPath: mockGetConfigDirectoryPath,
|
||||
}));
|
||||
vi.mock('../../../src/logger', () => ({
|
||||
default: {
|
||||
debug: vi.fn(),
|
||||
info: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
error: vi.fn(),
|
||||
},
|
||||
logRequestResponse: vi.fn(),
|
||||
}));
|
||||
vi.mock('../../../src/util/fetch/index', () => ({
|
||||
fetchWithProxy: mockFetchWithProxy,
|
||||
}));
|
||||
|
||||
describe('AzureVideoProvider', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockFetchWithProxy.mockReset();
|
||||
mockStoreMedia.mockReset();
|
||||
mockMediaExists.mockReset();
|
||||
mockGetMediaStorage.mockReset();
|
||||
mockGetConfigDirectoryPath.mockReset();
|
||||
|
||||
// Default config directory path
|
||||
mockGetConfigDirectoryPath.mockReturnValue('/test/config');
|
||||
|
||||
vi.mocked(fsPromises.readFile).mockRejectedValue(
|
||||
Object.assign(new Error('ENOENT: no such file or directory'), { code: 'ENOENT' }),
|
||||
);
|
||||
vi.mocked(fsPromises.mkdir).mockResolvedValue(undefined);
|
||||
vi.mocked(fsPromises.writeFile).mockResolvedValue(undefined);
|
||||
|
||||
// Default: no cached video exists
|
||||
const mockStorage = {
|
||||
exists: vi.fn().mockResolvedValue(false),
|
||||
};
|
||||
mockGetMediaStorage.mockReturnValue(mockStorage);
|
||||
|
||||
// Default store response
|
||||
mockStoreMedia.mockResolvedValue({
|
||||
ref: {
|
||||
provider: 'local',
|
||||
key: 'video/abc123.mp4',
|
||||
contentHash: 'abc123',
|
||||
metadata: {
|
||||
contentType: 'video/mp4',
|
||||
mediaType: 'video',
|
||||
},
|
||||
},
|
||||
deduplicated: false,
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.resetAllMocks();
|
||||
});
|
||||
|
||||
describe('validateAzureVideoDimensions', () => {
|
||||
it('should accept valid 1280x720 dimensions', () => {
|
||||
expect(validateAzureVideoDimensions(1280, 720)).toEqual({ valid: true });
|
||||
});
|
||||
|
||||
it('should accept valid 1920x1080 dimensions', () => {
|
||||
expect(validateAzureVideoDimensions(1920, 1080)).toEqual({ valid: true });
|
||||
});
|
||||
|
||||
it('should accept valid square dimensions', () => {
|
||||
expect(validateAzureVideoDimensions(720, 720)).toEqual({ valid: true });
|
||||
expect(validateAzureVideoDimensions(1080, 1080)).toEqual({ valid: true });
|
||||
});
|
||||
|
||||
it('should accept valid 854x480 dimensions', () => {
|
||||
expect(validateAzureVideoDimensions(854, 480)).toEqual({ valid: true });
|
||||
});
|
||||
|
||||
it('should reject invalid dimensions', () => {
|
||||
const result = validateAzureVideoDimensions(1920, 720);
|
||||
expect(result.valid).toBe(false);
|
||||
expect(result.message).toContain('Invalid video dimensions');
|
||||
expect(result.message).toContain('1920x720');
|
||||
});
|
||||
|
||||
it('should list valid sizes in error message', () => {
|
||||
const result = validateAzureVideoDimensions(999, 999);
|
||||
expect(result.message).toContain('480x480');
|
||||
expect(result.message).toContain('1280x720');
|
||||
});
|
||||
});
|
||||
|
||||
describe('validateAzureVideoDuration', () => {
|
||||
it('should accept valid durations', () => {
|
||||
for (const duration of AZURE_VIDEO_DURATIONS) {
|
||||
expect(validateAzureVideoDuration(duration)).toEqual({ valid: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('should reject invalid duration', () => {
|
||||
// TypeScript requires cast for invalid values
|
||||
const result = validateAzureVideoDuration(8 as 5);
|
||||
expect(result.valid).toBe(false);
|
||||
expect(result.message).toContain('Invalid video duration');
|
||||
});
|
||||
});
|
||||
|
||||
describe('calculateAzureVideoCost', () => {
|
||||
it('should calculate cost correctly for 5 seconds', () => {
|
||||
const cost = calculateAzureVideoCost(5, false);
|
||||
expect(cost).toBe(AZURE_SORA_COST_PER_SECOND * 5);
|
||||
});
|
||||
|
||||
it('should calculate cost correctly for 20 seconds', () => {
|
||||
const cost = calculateAzureVideoCost(20, false);
|
||||
expect(cost).toBe(AZURE_SORA_COST_PER_SECOND * 20);
|
||||
});
|
||||
|
||||
it('should return 0 for cached videos', () => {
|
||||
const cost = calculateAzureVideoCost(10, true);
|
||||
expect(cost).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('constructor', () => {
|
||||
it('should create provider with deployment name', () => {
|
||||
const provider = new AzureVideoProvider('sora', {
|
||||
config: {
|
||||
apiBaseUrl: 'https://test.cognitiveservices.azure.com',
|
||||
apiKey: 'test-key',
|
||||
},
|
||||
});
|
||||
expect(provider.id()).toBe('azure:video:sora');
|
||||
});
|
||||
|
||||
it('should use custom provider ID if specified', () => {
|
||||
const provider = new AzureVideoProvider('sora', {
|
||||
id: 'my-custom-video-provider',
|
||||
config: {
|
||||
apiBaseUrl: 'https://test.cognitiveservices.azure.com',
|
||||
apiKey: 'test-key',
|
||||
},
|
||||
});
|
||||
expect(provider.id()).toBe('my-custom-video-provider');
|
||||
});
|
||||
|
||||
it('should store config options', () => {
|
||||
const config = {
|
||||
apiBaseUrl: 'https://custom.azure.com',
|
||||
apiKey: 'custom-key',
|
||||
width: 1920,
|
||||
height: 1080,
|
||||
n_seconds: 10 as const,
|
||||
};
|
||||
const provider = new AzureVideoProvider('sora', { config });
|
||||
expect(provider.config).toEqual(config);
|
||||
});
|
||||
});
|
||||
|
||||
describe('toString', () => {
|
||||
it('should return descriptive string', () => {
|
||||
const provider = new AzureVideoProvider('my-deployment', {
|
||||
config: {
|
||||
apiBaseUrl: 'https://test.azure.com',
|
||||
apiKey: 'test-key',
|
||||
},
|
||||
});
|
||||
expect(provider.toString()).toBe('[Azure Video Provider my-deployment]');
|
||||
});
|
||||
});
|
||||
|
||||
describe('callApi - validation', () => {
|
||||
it('should reject invalid video dimensions', async () => {
|
||||
const provider = new AzureVideoProvider('sora', {
|
||||
config: {
|
||||
apiBaseUrl: 'https://test.azure.com',
|
||||
apiKey: 'test-key',
|
||||
width: 999,
|
||||
height: 999,
|
||||
},
|
||||
});
|
||||
|
||||
const result = await provider.callApi('Test prompt');
|
||||
|
||||
expect(result.error).toContain('Invalid video dimensions');
|
||||
});
|
||||
|
||||
it('should reject invalid video duration', async () => {
|
||||
const provider = new AzureVideoProvider('sora', {
|
||||
config: {
|
||||
apiBaseUrl: 'https://test.azure.com',
|
||||
apiKey: 'test-key',
|
||||
n_seconds: 7 as 5,
|
||||
},
|
||||
});
|
||||
|
||||
const result = await provider.callApi('Test prompt');
|
||||
|
||||
expect(result.error).toContain('Invalid video duration');
|
||||
});
|
||||
|
||||
it('should return error if API base URL is not set', async () => {
|
||||
const provider = new AzureVideoProvider('sora', {
|
||||
config: {
|
||||
apiKey: 'test-key',
|
||||
},
|
||||
});
|
||||
|
||||
const result = await provider.callApi('Test prompt');
|
||||
|
||||
// The error is returned during job creation since base URL is null
|
||||
expect(result.error).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('callApi - successful flow', () => {
|
||||
it('should create and poll video job successfully', async () => {
|
||||
const provider = new AzureVideoProvider('sora', {
|
||||
config: {
|
||||
apiBaseUrl: 'https://test.cognitiveservices.azure.com',
|
||||
apiKey: 'test-key',
|
||||
},
|
||||
});
|
||||
|
||||
// Mock job creation
|
||||
mockFetchWithProxy
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: () =>
|
||||
Promise.resolve({
|
||||
id: 'job_123',
|
||||
status: 'queued',
|
||||
generations: [],
|
||||
}),
|
||||
})
|
||||
// Mock status polling - succeeded
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: () =>
|
||||
Promise.resolve({
|
||||
id: 'job_123',
|
||||
status: 'succeeded',
|
||||
generations: [
|
||||
{
|
||||
id: 'gen_456',
|
||||
job_id: 'job_123',
|
||||
width: 1280,
|
||||
height: 720,
|
||||
n_seconds: 5,
|
||||
prompt: 'A cat playing piano',
|
||||
},
|
||||
],
|
||||
}),
|
||||
})
|
||||
// Mock video download
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
arrayBuffer: () => Promise.resolve(new ArrayBuffer(1000)),
|
||||
});
|
||||
|
||||
const result = await provider.callApi('A cat playing piano');
|
||||
|
||||
expect(result.error).toBeUndefined();
|
||||
expect(result.output).toContain('[Video:');
|
||||
expect(result.video).toBeDefined();
|
||||
expect(result.video?.format).toBe('mp4');
|
||||
expect(result.video?.size).toBe('1280x720');
|
||||
expect(result.video?.duration).toBe(5);
|
||||
expect(result.cached).toBe(false);
|
||||
});
|
||||
|
||||
it('should use custom dimensions and duration', async () => {
|
||||
const provider = new AzureVideoProvider('sora', {
|
||||
config: {
|
||||
apiBaseUrl: 'https://test.azure.com',
|
||||
apiKey: 'test-key',
|
||||
width: 1920,
|
||||
height: 1080,
|
||||
n_seconds: 10,
|
||||
},
|
||||
});
|
||||
|
||||
mockFetchWithProxy
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: () =>
|
||||
Promise.resolve({
|
||||
id: 'job_123',
|
||||
status: 'succeeded',
|
||||
generations: [{ id: 'gen_456', width: 1920, height: 1080, n_seconds: 10 }],
|
||||
}),
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: () =>
|
||||
Promise.resolve({
|
||||
id: 'job_123',
|
||||
status: 'succeeded',
|
||||
generations: [{ id: 'gen_456', width: 1920, height: 1080, n_seconds: 10 }],
|
||||
}),
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
arrayBuffer: () => Promise.resolve(new ArrayBuffer(1000)),
|
||||
});
|
||||
|
||||
const result = await provider.callApi('Test');
|
||||
|
||||
expect(result.video?.size).toBe('1920x1080');
|
||||
expect(result.video?.duration).toBe(10);
|
||||
});
|
||||
});
|
||||
|
||||
describe('callApi - error handling', () => {
|
||||
it('should handle job creation failure', async () => {
|
||||
const provider = new AzureVideoProvider('sora', {
|
||||
config: {
|
||||
apiBaseUrl: 'https://test.azure.com',
|
||||
apiKey: 'test-key',
|
||||
},
|
||||
});
|
||||
|
||||
mockFetchWithProxy.mockResolvedValueOnce({
|
||||
ok: false,
|
||||
status: 429,
|
||||
statusText: 'Too Many Requests',
|
||||
json: () =>
|
||||
Promise.resolve({
|
||||
error: { message: 'Rate limit exceeded' },
|
||||
}),
|
||||
});
|
||||
|
||||
const result = await provider.callApi('Test');
|
||||
|
||||
expect(result.error).toContain('Rate limit exceeded');
|
||||
});
|
||||
|
||||
it('should handle polling failure', async () => {
|
||||
const provider = new AzureVideoProvider('sora', {
|
||||
config: {
|
||||
apiBaseUrl: 'https://test.azure.com',
|
||||
apiKey: 'test-key',
|
||||
},
|
||||
});
|
||||
|
||||
// Job creation succeeds
|
||||
mockFetchWithProxy.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: () => Promise.resolve({ id: 'job_123', status: 'queued' }),
|
||||
});
|
||||
|
||||
// Status check fails
|
||||
mockFetchWithProxy.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: () =>
|
||||
Promise.resolve({
|
||||
id: 'job_123',
|
||||
status: 'failed',
|
||||
failure_reason: 'Content policy violation',
|
||||
}),
|
||||
});
|
||||
|
||||
const result = await provider.callApi('Test');
|
||||
|
||||
expect(result.error).toContain('Content policy violation');
|
||||
});
|
||||
|
||||
it('should handle video download failure', async () => {
|
||||
const provider = new AzureVideoProvider('sora', {
|
||||
config: {
|
||||
apiBaseUrl: 'https://test.azure.com',
|
||||
apiKey: 'test-key',
|
||||
},
|
||||
});
|
||||
|
||||
mockFetchWithProxy
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: () =>
|
||||
Promise.resolve({
|
||||
id: 'job_123',
|
||||
status: 'succeeded',
|
||||
generations: [{ id: 'gen_456' }],
|
||||
}),
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: () =>
|
||||
Promise.resolve({
|
||||
id: 'job_123',
|
||||
status: 'succeeded',
|
||||
generations: [{ id: 'gen_456' }],
|
||||
}),
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
ok: false,
|
||||
status: 404,
|
||||
statusText: 'Not Found',
|
||||
});
|
||||
|
||||
const result = await provider.callApi('Test');
|
||||
|
||||
expect(result.error).toContain('Failed to download video');
|
||||
});
|
||||
|
||||
it('should handle empty generations array', async () => {
|
||||
const provider = new AzureVideoProvider('sora', {
|
||||
config: {
|
||||
apiBaseUrl: 'https://test.azure.com',
|
||||
apiKey: 'test-key',
|
||||
},
|
||||
});
|
||||
|
||||
mockFetchWithProxy
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: () =>
|
||||
Promise.resolve({
|
||||
id: 'job_123',
|
||||
status: 'succeeded',
|
||||
generations: [],
|
||||
}),
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: () =>
|
||||
Promise.resolve({
|
||||
id: 'job_123',
|
||||
status: 'succeeded',
|
||||
generations: [],
|
||||
}),
|
||||
});
|
||||
|
||||
const result = await provider.callApi('Test');
|
||||
|
||||
expect(result.error).toContain('No video generations returned');
|
||||
});
|
||||
});
|
||||
|
||||
describe('callApi - caching', () => {
|
||||
it('should return cached video when available', async () => {
|
||||
const provider = new AzureVideoProvider('sora', {
|
||||
config: {
|
||||
apiBaseUrl: 'https://test.azure.com',
|
||||
apiKey: 'test-key',
|
||||
},
|
||||
});
|
||||
|
||||
// Set up cache hit
|
||||
vi.mocked(fsPromises.readFile).mockResolvedValue(
|
||||
JSON.stringify({
|
||||
videoKey: 'video/cached123.mp4',
|
||||
createdAt: '2024-01-01T00:00:00Z',
|
||||
}),
|
||||
);
|
||||
mockGetMediaStorage.mockReturnValue({
|
||||
exists: vi.fn().mockResolvedValue(true),
|
||||
});
|
||||
|
||||
const result = await provider.callApi('Test prompt');
|
||||
|
||||
expect(result.cached).toBe(true);
|
||||
expect(result.latencyMs).toBe(0);
|
||||
expect(result.cost).toBe(0);
|
||||
expect(result.video?.storageRef?.key).toBe('video/cached123.mp4');
|
||||
|
||||
// Should not have made any fetch calls
|
||||
expect(mockFetchWithProxy).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('generateVideoCacheKey for Azure', () => {
|
||||
it('should generate deterministic cache keys', () => {
|
||||
const key1 = generateVideoCacheKey({
|
||||
provider: 'azure',
|
||||
prompt: 'A cat playing piano',
|
||||
model: 'sora',
|
||||
size: '1280x720',
|
||||
seconds: 5,
|
||||
});
|
||||
|
||||
const key2 = generateVideoCacheKey({
|
||||
provider: 'azure',
|
||||
prompt: 'A cat playing piano',
|
||||
model: 'sora',
|
||||
size: '1280x720',
|
||||
seconds: 5,
|
||||
});
|
||||
|
||||
expect(key1).toBe(key2);
|
||||
});
|
||||
|
||||
it('should generate different keys for different prompts', () => {
|
||||
const key1 = generateVideoCacheKey({
|
||||
provider: 'azure',
|
||||
prompt: 'A cat',
|
||||
model: 'sora',
|
||||
size: '1280x720',
|
||||
seconds: 5,
|
||||
});
|
||||
|
||||
const key2 = generateVideoCacheKey({
|
||||
provider: 'azure',
|
||||
prompt: 'A dog',
|
||||
model: 'sora',
|
||||
size: '1280x720',
|
||||
seconds: 5,
|
||||
});
|
||||
|
||||
expect(key1).not.toBe(key2);
|
||||
});
|
||||
|
||||
it('should generate different keys for azure vs openai provider', () => {
|
||||
const azureKey = generateVideoCacheKey({
|
||||
provider: 'azure',
|
||||
prompt: 'Test',
|
||||
model: 'sora',
|
||||
size: '1280x720',
|
||||
seconds: 5,
|
||||
});
|
||||
|
||||
const openaiKey = generateVideoCacheKey({
|
||||
provider: 'openai',
|
||||
prompt: 'Test',
|
||||
model: 'sora',
|
||||
size: '1280x720',
|
||||
seconds: 5,
|
||||
});
|
||||
|
||||
expect(azureKey).not.toBe(openaiKey);
|
||||
});
|
||||
});
|
||||
|
||||
describe('AZURE_VIDEO_DIMENSIONS', () => {
|
||||
it('should have all required dimensions', () => {
|
||||
expect(Object.keys(AZURE_VIDEO_DIMENSIONS)).toContain('480x480');
|
||||
expect(Object.keys(AZURE_VIDEO_DIMENSIONS)).toContain('854x480');
|
||||
expect(Object.keys(AZURE_VIDEO_DIMENSIONS)).toContain('720x720');
|
||||
expect(Object.keys(AZURE_VIDEO_DIMENSIONS)).toContain('1280x720');
|
||||
expect(Object.keys(AZURE_VIDEO_DIMENSIONS)).toContain('1080x1080');
|
||||
expect(Object.keys(AZURE_VIDEO_DIMENSIONS)).toContain('1920x1080');
|
||||
});
|
||||
|
||||
it('should have correct width and height for each dimension', () => {
|
||||
expect(AZURE_VIDEO_DIMENSIONS['1280x720']).toEqual({ width: 1280, height: 720 });
|
||||
expect(AZURE_VIDEO_DIMENSIONS['1920x1080']).toEqual({ width: 1920, height: 1080 });
|
||||
});
|
||||
});
|
||||
|
||||
describe('AZURE_VIDEO_DURATIONS', () => {
|
||||
it('should include valid durations', () => {
|
||||
expect(AZURE_VIDEO_DURATIONS).toContain(5);
|
||||
expect(AZURE_VIDEO_DURATIONS).toContain(10);
|
||||
expect(AZURE_VIDEO_DURATIONS).toContain(15);
|
||||
expect(AZURE_VIDEO_DURATIONS).toContain(20);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,128 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import logger from '../../../src/logger';
|
||||
import { maybeEmitAzureOpenAiWarning } from '../../../src/providers/azure/warnings';
|
||||
|
||||
import type { TestCase, TestSuite } from '../../../src/types/index';
|
||||
|
||||
describe('maybeEmitAzureOpenAiWarning', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('should return false when no Azure providers are present', () => {
|
||||
const testSuite: TestSuite = {
|
||||
prompts: [],
|
||||
providers: [{ constructor: { name: 'OtherProvider' } } as any],
|
||||
defaultTest: {},
|
||||
};
|
||||
const tests: TestCase[] = [];
|
||||
|
||||
expect(maybeEmitAzureOpenAiWarning(testSuite, tests)).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false when both Azure and OpenAI providers are present', () => {
|
||||
const testSuite: TestSuite = {
|
||||
prompts: [],
|
||||
providers: [
|
||||
{ constructor: { name: 'AzureChatCompletionProvider' } } as any,
|
||||
{ constructor: { name: 'OpenAiChatCompletionProvider' } } as any,
|
||||
],
|
||||
defaultTest: {},
|
||||
};
|
||||
const tests: TestCase[] = [];
|
||||
|
||||
expect(maybeEmitAzureOpenAiWarning(testSuite, tests)).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false when Azure provider is present but no model-graded assertions', () => {
|
||||
const testSuite: TestSuite = {
|
||||
prompts: [],
|
||||
providers: [{ constructor: { name: 'AzureChatCompletionProvider' } } as any],
|
||||
defaultTest: {},
|
||||
};
|
||||
const tests: TestCase[] = [
|
||||
{
|
||||
assert: [{ type: 'contains' }],
|
||||
},
|
||||
];
|
||||
|
||||
expect(maybeEmitAzureOpenAiWarning(testSuite, tests)).toBe(false);
|
||||
});
|
||||
|
||||
it('should return true and emit warning when Azure provider is used with model-graded assertions', () => {
|
||||
const warnSpy = vi.spyOn(logger, 'warn');
|
||||
|
||||
const testSuite: TestSuite = {
|
||||
prompts: [],
|
||||
providers: [{ constructor: { name: 'AzureChatCompletionProvider' } } as any],
|
||||
defaultTest: {},
|
||||
};
|
||||
const tests: TestCase[] = [
|
||||
{
|
||||
assert: [{ type: 'factuality' }],
|
||||
},
|
||||
];
|
||||
|
||||
expect(maybeEmitAzureOpenAiWarning(testSuite, tests)).toBe(true);
|
||||
expect(warnSpy).toHaveBeenCalledWith(
|
||||
expect.stringContaining('You are using model-graded assertions of types'),
|
||||
);
|
||||
});
|
||||
|
||||
it('should return false when provider is explicitly set', () => {
|
||||
const testSuite: TestSuite = {
|
||||
prompts: [],
|
||||
providers: [{ constructor: { name: 'AzureChatCompletionProvider' } } as any],
|
||||
defaultTest: {
|
||||
options: {
|
||||
provider: 'azure',
|
||||
},
|
||||
},
|
||||
};
|
||||
const tests: TestCase[] = [
|
||||
{
|
||||
assert: [{ type: 'factuality' }],
|
||||
},
|
||||
];
|
||||
|
||||
expect(maybeEmitAzureOpenAiWarning(testSuite, tests)).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false when assertion has provider set', () => {
|
||||
const testSuite: TestSuite = {
|
||||
prompts: [],
|
||||
providers: [{ constructor: { name: 'AzureChatCompletionProvider' } } as any],
|
||||
defaultTest: {},
|
||||
};
|
||||
const tests: TestCase[] = [
|
||||
{
|
||||
assert: [
|
||||
{
|
||||
type: 'factuality',
|
||||
provider: 'azure',
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
expect(maybeEmitAzureOpenAiWarning(testSuite, tests)).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false when test has provider option set', () => {
|
||||
const testSuite: TestSuite = {
|
||||
prompts: [],
|
||||
providers: [{ constructor: { name: 'AzureChatCompletionProvider' } } as any],
|
||||
defaultTest: {},
|
||||
};
|
||||
const tests: TestCase[] = [
|
||||
{
|
||||
assert: [{ type: 'factuality' }],
|
||||
options: {
|
||||
provider: 'azure',
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
expect(maybeEmitAzureOpenAiWarning(testSuite, tests)).toBe(false);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user