chore: import upstream snapshot with attribution
CI / Shell Format Check (push) Has been cancelled
CI / Check Ruby (3.4) (push) Has been cancelled
CI / CI Config (push) Has been cancelled
CI / Test on Node ${{ matrix.node }} and ${{ matrix.os }}${{ matrix.shard && format(' (shard {0}/3)', matrix.shard) || '' }} (push) Has been cancelled
CI / Build on Node ${{ matrix.node }} (push) Has been cancelled
CI / Style Check (push) Has been cancelled
CI / Generate Assets (push) Has been cancelled
CI / Check Python (3.14) (push) Has been cancelled
CI / Check Python (3.9) (push) Has been cancelled
CI / Build Docs (push) Has been cancelled
CI / Code Scan Action (push) Has been cancelled
CI / Site tests (push) Has been cancelled
CI / webui tests (push) Has been cancelled
CI / Run Integration Tests (push) Has been cancelled
CI / Run Smoke Tests (push) Has been cancelled
CI / Go Tests (push) Has been cancelled
CI / Share Test (push) Has been cancelled
CI / Redteam (Production API) (push) Has been cancelled
CI / Redteam (Staging API) (push) Has been cancelled
CI / GitHub Actions Lint (push) Has been cancelled
CI / Check Ruby (3.0) (push) Has been cancelled
release-please / release-please (push) Has been cancelled
release-please / build (push) Has been cancelled
release-please / publish-npm (push) Has been cancelled
release-please / publish-npm-backfill (push) Has been cancelled
release-please / docker (push) Has been cancelled
release-please / publish-code-scan-action (push) Has been cancelled
release-please / attest-code-scan-action (push) Has been cancelled
Deploy local.promptfoo.app / Deploy to Cloudflare Pages (push) Has been cancelled
Test and Publish Multi-arch Docker Image / test (push) Has been cancelled
Test and Publish Multi-arch Docker Image / build-docker-and-push-digests (map[digest-suffix:linux-amd64 platform:linux/amd64 runner:ubuntu-latest]) (push) Has been cancelled
Test and Publish Multi-arch Docker Image / build-docker-and-push-digests (map[digest-suffix:linux-arm64 platform:linux/arm64 runner:ubuntu-24.04-arm]) (push) Has been cancelled
Test and Publish Multi-arch Docker Image / merge-docker-digests (push) Has been cancelled
Test and Publish Multi-arch Docker Image / Attest Multi-arch Image (push) Has been cancelled
Validate Renovate Config / Validate Renovate Configuration (push) Has been cancelled
CI / Shell Format Check (push) Has been cancelled
CI / Check Ruby (3.4) (push) Has been cancelled
CI / CI Config (push) Has been cancelled
CI / Test on Node ${{ matrix.node }} and ${{ matrix.os }}${{ matrix.shard && format(' (shard {0}/3)', matrix.shard) || '' }} (push) Has been cancelled
CI / Build on Node ${{ matrix.node }} (push) Has been cancelled
CI / Style Check (push) Has been cancelled
CI / Generate Assets (push) Has been cancelled
CI / Check Python (3.14) (push) Has been cancelled
CI / Check Python (3.9) (push) Has been cancelled
CI / Build Docs (push) Has been cancelled
CI / Code Scan Action (push) Has been cancelled
CI / Site tests (push) Has been cancelled
CI / webui tests (push) Has been cancelled
CI / Run Integration Tests (push) Has been cancelled
CI / Run Smoke Tests (push) Has been cancelled
CI / Go Tests (push) Has been cancelled
CI / Share Test (push) Has been cancelled
CI / Redteam (Production API) (push) Has been cancelled
CI / Redteam (Staging API) (push) Has been cancelled
CI / GitHub Actions Lint (push) Has been cancelled
CI / Check Ruby (3.0) (push) Has been cancelled
release-please / release-please (push) Has been cancelled
release-please / build (push) Has been cancelled
release-please / publish-npm (push) Has been cancelled
release-please / publish-npm-backfill (push) Has been cancelled
release-please / docker (push) Has been cancelled
release-please / publish-code-scan-action (push) Has been cancelled
release-please / attest-code-scan-action (push) Has been cancelled
Deploy local.promptfoo.app / Deploy to Cloudflare Pages (push) Has been cancelled
Test and Publish Multi-arch Docker Image / test (push) Has been cancelled
Test and Publish Multi-arch Docker Image / build-docker-and-push-digests (map[digest-suffix:linux-amd64 platform:linux/amd64 runner:ubuntu-latest]) (push) Has been cancelled
Test and Publish Multi-arch Docker Image / build-docker-and-push-digests (map[digest-suffix:linux-arm64 platform:linux/arm64 runner:ubuntu-24.04-arm]) (push) Has been cancelled
Test and Publish Multi-arch Docker Image / merge-docker-digests (push) Has been cancelled
Test and Publish Multi-arch Docker Image / Attest Multi-arch Image (push) Has been cancelled
Validate Renovate Config / Validate Renovate Configuration (push) Has been cancelled
This commit is contained in:
@@ -0,0 +1,357 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { fetchWithCache } from '../../src/cache';
|
||||
import { ModelsLabImageProvider } from '../../src/providers/modelslab';
|
||||
|
||||
import type { CallApiContextParams } from '../../src/types/providers';
|
||||
|
||||
vi.mock('../../src/cache');
|
||||
vi.mock('../../src/blobs/extractor', () => ({
|
||||
isBlobStorageEnabled: vi.fn().mockReturnValue(false),
|
||||
}));
|
||||
vi.mock('../../src/blobs/index', () => ({
|
||||
storeBlob: vi.fn(),
|
||||
}));
|
||||
vi.mock('../../src/util/fetch/index', () => ({
|
||||
fetchWithProxy: vi.fn(),
|
||||
}));
|
||||
vi.mock(import('../../src/envars'), async (importOriginal) => {
|
||||
const actual = await importOriginal();
|
||||
return {
|
||||
...actual,
|
||||
getEnvString: vi.fn().mockReturnValue(undefined),
|
||||
};
|
||||
});
|
||||
|
||||
const mockedFetchWithCache = vi.mocked(fetchWithCache);
|
||||
|
||||
function mockResponse(
|
||||
data: Record<string, unknown>,
|
||||
status = 200,
|
||||
statusText = 'OK',
|
||||
cached = false,
|
||||
) {
|
||||
return { data, cached, status, statusText, deleteFromCache: vi.fn() };
|
||||
}
|
||||
|
||||
describe('ModelsLabImageProvider', () => {
|
||||
const mockApiKey = 'test-modelslab-api-key';
|
||||
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.resetAllMocks();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it('returns correct id and toString', () => {
|
||||
const provider = new ModelsLabImageProvider('flux', { config: { apiKey: mockApiKey } });
|
||||
expect(provider.id()).toBe('modelslab:image:flux');
|
||||
expect(provider.toString()).toBe('[ModelsLab Image Provider flux]');
|
||||
});
|
||||
|
||||
it('supports custom id override', () => {
|
||||
const provider = new ModelsLabImageProvider('flux', {
|
||||
id: 'my-custom-id',
|
||||
config: { apiKey: mockApiKey },
|
||||
});
|
||||
expect(provider.id()).toBe('my-custom-id');
|
||||
});
|
||||
|
||||
it('generates an image with successful response', async () => {
|
||||
mockedFetchWithCache.mockResolvedValue(
|
||||
mockResponse({
|
||||
status: 'success',
|
||||
output: ['https://modelslab.com/output/flux-image-123.jpg'],
|
||||
}),
|
||||
);
|
||||
|
||||
const provider = new ModelsLabImageProvider('flux', {
|
||||
config: { apiKey: mockApiKey, width: 1024, height: 1024 },
|
||||
});
|
||||
|
||||
const result = await provider.callApi('A serene mountain landscape at sunset');
|
||||
|
||||
expect(result.output).toBe(
|
||||
'',
|
||||
);
|
||||
expect(result.error).toBeUndefined();
|
||||
|
||||
expect(mockedFetchWithCache).toHaveBeenCalledWith(
|
||||
'https://modelslab.com/api/v6/images/text2img',
|
||||
expect.objectContaining({
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: expect.stringContaining('"key":"test-modelslab-api-key"'),
|
||||
}),
|
||||
expect.any(Number),
|
||||
'json',
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
it('propagates cached flag from fetchWithCache response', async () => {
|
||||
mockedFetchWithCache.mockResolvedValue(
|
||||
mockResponse(
|
||||
{ status: 'success', output: ['https://modelslab.com/output/cached.jpg'] },
|
||||
200,
|
||||
'OK',
|
||||
true,
|
||||
),
|
||||
);
|
||||
|
||||
const provider = new ModelsLabImageProvider('flux', { config: { apiKey: mockApiKey } });
|
||||
const result = await provider.callApi('Test');
|
||||
|
||||
expect(result.cached).toBe(true);
|
||||
});
|
||||
|
||||
it('sets cached to false after polling completes', async () => {
|
||||
mockedFetchWithCache
|
||||
.mockResolvedValueOnce(
|
||||
mockResponse({
|
||||
status: 'processing',
|
||||
id: 1,
|
||||
request_id: 'req_poll',
|
||||
fetch_result: '',
|
||||
eta: 5,
|
||||
}),
|
||||
)
|
||||
.mockResolvedValueOnce(
|
||||
mockResponse({
|
||||
status: 'success',
|
||||
output: ['https://modelslab.com/output/polled.jpg'],
|
||||
}),
|
||||
);
|
||||
|
||||
const provider = new ModelsLabImageProvider('flux', { config: { apiKey: mockApiKey } });
|
||||
const resultPromise = provider.callApi('Poll test');
|
||||
await vi.runAllTimersAsync();
|
||||
const result = await resultPromise;
|
||||
|
||||
expect(result.cached).toBe(false);
|
||||
expect(result.output).toContain('polled.jpg');
|
||||
});
|
||||
|
||||
it('merges per-prompt config overrides from context', async () => {
|
||||
mockedFetchWithCache.mockResolvedValue(
|
||||
mockResponse({
|
||||
status: 'success',
|
||||
output: ['https://modelslab.com/output/wide.jpg'],
|
||||
}),
|
||||
);
|
||||
|
||||
const provider = new ModelsLabImageProvider('flux', {
|
||||
config: { apiKey: mockApiKey, width: 512, height: 512 },
|
||||
});
|
||||
|
||||
await provider.callApi('Test', {
|
||||
prompt: { raw: 'Test', label: 'Test', config: { width: 1920, height: 1080 } },
|
||||
vars: {},
|
||||
});
|
||||
|
||||
const body = JSON.parse((mockedFetchWithCache.mock.calls[0][1] as RequestInit).body as string);
|
||||
expect(body.width).toBe(1920);
|
||||
expect(body.height).toBe(1080);
|
||||
});
|
||||
|
||||
it('polls for completion when status is processing', async () => {
|
||||
mockedFetchWithCache
|
||||
.mockResolvedValueOnce(
|
||||
mockResponse({
|
||||
status: 'processing',
|
||||
id: 12345,
|
||||
request_id: 'req_abc123',
|
||||
fetch_result: 'https://modelslab.com/api/v6/images/fetch/12345',
|
||||
eta: 30,
|
||||
}),
|
||||
)
|
||||
.mockResolvedValueOnce(
|
||||
mockResponse({
|
||||
status: 'success',
|
||||
output: ['https://modelslab.com/output/processed-image.jpg'],
|
||||
}),
|
||||
);
|
||||
|
||||
const provider = new ModelsLabImageProvider('flux', {
|
||||
config: { apiKey: mockApiKey },
|
||||
});
|
||||
|
||||
const resultPromise = provider.callApi('A futuristic city');
|
||||
await vi.runAllTimersAsync();
|
||||
const result = await resultPromise;
|
||||
|
||||
expect(result.output).toContain('processed-image.jpg');
|
||||
expect(mockedFetchWithCache).toHaveBeenCalledTimes(2);
|
||||
|
||||
// Verify poll request uses correct URL, sends API key, and busts cache
|
||||
expect(mockedFetchWithCache).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
'https://modelslab.com/api/v6/images/fetch/req_abc123',
|
||||
expect.objectContaining({
|
||||
method: 'POST',
|
||||
body: expect.stringContaining('"key":"test-modelslab-api-key"'),
|
||||
}),
|
||||
expect.any(Number),
|
||||
'json',
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
it('continues polling after transient network failure', async () => {
|
||||
mockedFetchWithCache
|
||||
.mockResolvedValueOnce(
|
||||
mockResponse({
|
||||
status: 'processing',
|
||||
id: 99,
|
||||
request_id: 'req_x',
|
||||
fetch_result: '',
|
||||
eta: 10,
|
||||
}),
|
||||
)
|
||||
.mockRejectedValueOnce(new Error('Network timeout'))
|
||||
.mockResolvedValueOnce(
|
||||
mockResponse({
|
||||
status: 'success',
|
||||
output: ['https://cdn.modelslab.com/img.jpg'],
|
||||
}),
|
||||
);
|
||||
|
||||
const provider = new ModelsLabImageProvider('flux', { config: { apiKey: mockApiKey } });
|
||||
const resultPromise = provider.callApi('A cat');
|
||||
await vi.runAllTimersAsync();
|
||||
const result = await resultPromise;
|
||||
|
||||
expect(result.output).toContain('img.jpg');
|
||||
expect(mockedFetchWithCache).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
|
||||
it('returns error when API returns error status', async () => {
|
||||
mockedFetchWithCache.mockResolvedValue(
|
||||
mockResponse({ status: 'error', message: 'Invalid API key' }, 400, 'Bad Request'),
|
||||
);
|
||||
|
||||
const provider = new ModelsLabImageProvider('flux', { config: { apiKey: mockApiKey } });
|
||||
const result = await provider.callApi('Test prompt');
|
||||
|
||||
expect(result.error).toContain('Invalid API key');
|
||||
});
|
||||
|
||||
it('returns error when API returns server error body', async () => {
|
||||
mockedFetchWithCache.mockResolvedValue(
|
||||
mockResponse(
|
||||
{ status: 'error', message: 'Internal Server Error' },
|
||||
500,
|
||||
'Internal Server Error',
|
||||
),
|
||||
);
|
||||
|
||||
const provider = new ModelsLabImageProvider('flux', { config: { apiKey: mockApiKey } });
|
||||
const result = await provider.callApi('Test prompt');
|
||||
|
||||
expect(result.error).toContain('Internal Server Error');
|
||||
});
|
||||
|
||||
it('returns error when fetch throws (e.g. rate limit)', async () => {
|
||||
mockedFetchWithCache.mockRejectedValue(new Error('429 Too Many Requests'));
|
||||
|
||||
const provider = new ModelsLabImageProvider('flux', { config: { apiKey: mockApiKey } });
|
||||
const result = await provider.callApi('Test prompt');
|
||||
|
||||
expect(result.error).toContain('ModelsLab API call error');
|
||||
expect(result.error).toContain('429 Too Many Requests');
|
||||
});
|
||||
|
||||
it('returns error when no API key is provided', async () => {
|
||||
const provider = new ModelsLabImageProvider('flux', {});
|
||||
const result = await provider.callApi('Test prompt');
|
||||
|
||||
expect(result.error).toContain('API key is not set');
|
||||
});
|
||||
|
||||
it('returns error when API returns empty output array', async () => {
|
||||
mockedFetchWithCache.mockResolvedValue(mockResponse({ status: 'success', output: [] }));
|
||||
|
||||
const provider = new ModelsLabImageProvider('flux', { config: { apiKey: mockApiKey } });
|
||||
const result = await provider.callApi('Test prompt');
|
||||
|
||||
expect(result.error).toContain('no image URLs');
|
||||
});
|
||||
|
||||
it('uses default width/height when not configured', async () => {
|
||||
mockedFetchWithCache.mockResolvedValue(
|
||||
mockResponse({ status: 'success', output: ['https://modelslab.com/output/test.jpg'] }),
|
||||
);
|
||||
|
||||
const provider = new ModelsLabImageProvider('flux', { config: { apiKey: mockApiKey } });
|
||||
await provider.callApi('Test');
|
||||
|
||||
const body = JSON.parse((mockedFetchWithCache.mock.calls[0][1] as RequestInit).body as string);
|
||||
expect(body.width).toBe(512);
|
||||
expect(body.height).toBe(512);
|
||||
});
|
||||
|
||||
it('reads API key from env override', () => {
|
||||
const provider = new ModelsLabImageProvider('flux', {
|
||||
env: { MODELSLAB_API_KEY: 'env-key' },
|
||||
});
|
||||
// Provider should have picked up the env key (we verify indirectly - no error on construction)
|
||||
expect(provider.id()).toBe('modelslab:image:flux');
|
||||
});
|
||||
|
||||
it('downloads image to blob storage when enabled', async () => {
|
||||
const { isBlobStorageEnabled } = await import('../../src/blobs/extractor');
|
||||
const { storeBlob } = await import('../../src/blobs/index');
|
||||
const { fetchWithProxy } = await import('../../src/util/fetch/index');
|
||||
vi.mocked(isBlobStorageEnabled).mockReturnValue(true);
|
||||
vi.mocked(storeBlob).mockResolvedValue({
|
||||
ref: {
|
||||
uri: 'promptfoo://blob/abc123',
|
||||
hash: 'abc123',
|
||||
mimeType: 'image/png',
|
||||
sizeBytes: 1024,
|
||||
provider: 'filesystem',
|
||||
},
|
||||
deduplicated: false,
|
||||
});
|
||||
vi.mocked(fetchWithProxy).mockResolvedValue({
|
||||
ok: true,
|
||||
headers: new Headers({ 'content-type': 'image/jpeg' }),
|
||||
arrayBuffer: async () => new ArrayBuffer(1024),
|
||||
} as Response);
|
||||
|
||||
mockedFetchWithCache.mockResolvedValue(
|
||||
mockResponse({
|
||||
status: 'success',
|
||||
output: ['https://modelslab.com/output/blob-test.jpg'],
|
||||
}),
|
||||
);
|
||||
|
||||
const provider = new ModelsLabImageProvider('flux', { config: { apiKey: mockApiKey } });
|
||||
const result = await provider.callApi('Blob test', {
|
||||
evaluationId: 'eval-modelslab',
|
||||
promptIdx: 2,
|
||||
testIdx: 1,
|
||||
} as unknown as CallApiContextParams);
|
||||
|
||||
expect(result.output).toContain('promptfoo://blob/abc123');
|
||||
expect(result.metadata).toEqual(
|
||||
expect.objectContaining({
|
||||
blobRef: expect.objectContaining({ uri: 'promptfoo://blob/abc123', hash: 'abc123' }),
|
||||
blobHash: 'abc123',
|
||||
}),
|
||||
);
|
||||
expect(fetchWithProxy).toHaveBeenCalledWith('https://modelslab.com/output/blob-test.jpg');
|
||||
expect(storeBlob).toHaveBeenCalledWith(
|
||||
expect.any(Buffer),
|
||||
'image/jpeg',
|
||||
expect.objectContaining({
|
||||
evalId: 'eval-modelslab',
|
||||
kind: 'image',
|
||||
promptIdx: 2,
|
||||
testIdx: 1,
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user