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

This commit is contained in:
wehub-resource-sync
2026-07-13 13:24:08 +08:00
commit 0d3cb498a3
5438 changed files with 1316560 additions and 0 deletions
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
+287
View File
@@ -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,
);
});
});
+159
View File
@@ -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,
},
});
});
});
+98
View File
@@ -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);
});
});
+842
View File
@@ -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');
});
});
});
});
+107
View File
@@ -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
});
});
});
+300
View File
@@ -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 });
});
});
+112
View File
@@ -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);
});
});
+364
View File
@@ -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,
});
});
});
+626
View File
@@ -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,
);
});
});
});
+320
View File
@@ -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);
});
});
+611
View File
@@ -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);
});
});
});
+128
View File
@@ -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);
});
});