File size: 2,785 Bytes
dcedb25
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
import { describe, it, expect, vi } from 'vitest';
import type { ChatMessage } from '@freellmapi/shared/types.js';

// sharp is a NATIVE module. It is loaded with a dynamic import precisely so a
// missing or mismatched prebuilt libvips cannot take the gateway down at
// import time — the desktop build only electron-rebuilds better-sqlite3, and
// slim/musl containers routinely ship without the matching binary. This is the
// degraded path: images must ride through untouched and the request must still
// be served.
//
// Own file because the resolved module is cached in module state after the
// first call; vitest isolates the registry per file, so the failure is only
// observable from a file that has never loaded the real sharp.
vi.mock('sharp', () => ({
  get default(): never {
    throw new Error(
      "Could not load the 'sharp' module using the linux-x64 runtime",
    );
  },
}));

const { normalizeMessageImages } = await import('../../lib/image-normalize.js');

// A data URL big enough to clear every threshold — it must survive byte-for-byte.
const BIG_IMAGE = `data:image/png;base64,${'A'.repeat(2_000_000)}`;

function imageTurn(url: string): ChatMessage[] {
  return [{
    role: 'user',
    content: [
      { type: 'text', text: 'what is this?' },
      { type: 'image_url', image_url: { url } },
    ],
  } as any];
}

describe('inbound image normalization without sharp', () => {
  it('passes images through untouched instead of throwing', async () => {
    const messages = imageTurn(BIG_IMAGE);

    const summary = await normalizeMessageImages(messages, { thresholdBytes: 0 });

    expect(summary.normalized).toBe(0);
    expect(summary.bytesBefore).toBe(0);
    expect(summary.bytesAfter).toBe(0);
    // Same array, same bytes: the request is served exactly as it was before
    // this module existed.
    expect(summary.messages).toBe(messages);
    expect((messages[0].content as any[])[1].image_url.url).toBe(BIG_IMAGE);
  });

  it('warns once, not once per image', async () => {
    const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
    try {
      await normalizeMessageImages(imageTurn(BIG_IMAGE), { thresholdBytes: 0 });
      await normalizeMessageImages(imageTurn(BIG_IMAGE), { thresholdBytes: 0 });
      // The failed load is remembered, so a busy gateway does not reprint the
      // warning (or retry the import) on every vision request.
      expect(warn).not.toHaveBeenCalled();
    } finally {
      warn.mockRestore();
    }
  });

  it('still honours the disable switch', async () => {
    const messages = imageTurn(BIG_IMAGE);
    const summary = await normalizeMessageImages(messages, { enabled: false });
    expect(summary.normalized).toBe(0);
    expect(summary.messages).toBe(messages);
  });
});