0d3cb498a3
CI / Shell Format Check (push) Has been cancelled
CI / Check Ruby (3.4) (push) Has been cancelled
CI / CI Config (push) Has been cancelled
CI / Test on Node ${{ matrix.node }} and ${{ matrix.os }}${{ matrix.shard && format(' (shard {0}/3)', matrix.shard) || '' }} (push) Has been cancelled
CI / Build on Node ${{ matrix.node }} (push) Has been cancelled
CI / Style Check (push) Has been cancelled
CI / Generate Assets (push) Has been cancelled
CI / Check Python (3.14) (push) Has been cancelled
CI / Check Python (3.9) (push) Has been cancelled
CI / Build Docs (push) Has been cancelled
CI / Code Scan Action (push) Has been cancelled
CI / Site tests (push) Has been cancelled
CI / webui tests (push) Has been cancelled
CI / Run Integration Tests (push) Has been cancelled
CI / Run Smoke Tests (push) Has been cancelled
CI / Go Tests (push) Has been cancelled
CI / Share Test (push) Has been cancelled
CI / Redteam (Production API) (push) Has been cancelled
CI / Redteam (Staging API) (push) Has been cancelled
CI / GitHub Actions Lint (push) Has been cancelled
CI / Check Ruby (3.0) (push) Has been cancelled
release-please / release-please (push) Has been cancelled
release-please / build (push) Has been cancelled
release-please / publish-npm (push) Has been cancelled
release-please / publish-npm-backfill (push) Has been cancelled
release-please / docker (push) Has been cancelled
release-please / publish-code-scan-action (push) Has been cancelled
release-please / attest-code-scan-action (push) Has been cancelled
Deploy local.promptfoo.app / Deploy to Cloudflare Pages (push) Has been cancelled
Test and Publish Multi-arch Docker Image / test (push) Has been cancelled
Test and Publish Multi-arch Docker Image / build-docker-and-push-digests (map[digest-suffix:linux-amd64 platform:linux/amd64 runner:ubuntu-latest]) (push) Has been cancelled
Test and Publish Multi-arch Docker Image / build-docker-and-push-digests (map[digest-suffix:linux-arm64 platform:linux/arm64 runner:ubuntu-24.04-arm]) (push) Has been cancelled
Test and Publish Multi-arch Docker Image / merge-docker-digests (push) Has been cancelled
Test and Publish Multi-arch Docker Image / Attest Multi-arch Image (push) Has been cancelled
Validate Renovate Config / Validate Renovate Configuration (push) Has been cancelled
843 lines
29 KiB
TypeScript
843 lines
29 KiB
TypeScript
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');
|
|
});
|
|
});
|
|
});
|
|
});
|