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

301 lines
10 KiB
TypeScript

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 });
});
});