feat: make openAICompatibleProvider work with Infomaniak out of the box (0.3.0)

- response_format is omitted by default (Infomaniak rejects the legacy
  json_object → HTTP 422); opt in via the new `responseFormat` option
  ({type:'json_object'} or a json_schema object). BREAKING for endpoints
  that relied on the forced json_object.
- parse LLM JSON leniently (tolerate markdown fences / surrounding prose)
- fold strict-coherence rules into DEFAULT_SYSTEM_PROMPT (exact
  placeholder<->mapping-key identity, values are originals, strict format,
  mask value not adjacent label) → reliable output across models

Verified live against Gemma 4 (google/gemma-4-31B-it, Infomaniak v2): all
demo phrases anonymize with clean round-trips, no custom provider needed.
36 tests passing.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Mobiletic
2026-07-01 09:43:19 +01:00
parent 669794522e
commit f5c9f0fbd4
5 changed files with 84 additions and 9 deletions

View File

@@ -20,7 +20,7 @@ describe('openAICompatibleProvider', () => {
expect(openAICompatibleProvider({ ...opts, apiKey: '' }).isConfigured()).toBe(false);
});
it('anonymize() parses {texte_anonymise, mapping} and posts temperature 0 + json_object', async () => {
it('anonymize() parses {texte_anonymise, mapping} and posts temperature 0, no response_format by default', async () => {
const fetchMock = mockFetchJson({ texte_anonymise: '[EMAIL_1]', mapping: { '[EMAIL_1]': 'a@b.ch' } });
vi.stubGlobal('fetch', fetchMock);
@@ -31,10 +31,31 @@ describe('openAICompatibleProvider', () => {
expect(url).toBe('https://api.example.com/v1/chat/completions');
const body = JSON.parse(init.body);
expect(body.temperature).toBe(0);
expect(body.response_format).toEqual({ type: 'json_object' });
expect(body.response_format).toBeUndefined(); // omitted by default (Infomaniak rejects json_object)
expect(init.headers.Authorization).toBe('Bearer k');
});
it('sends response_format only when the option is provided', async () => {
const fetchMock = mockFetchJson({ texte_anonymise: 'x', mapping: {} });
vi.stubGlobal('fetch', fetchMock);
await openAICompatibleProvider({ ...opts, responseFormat: { type: 'json_object' } }).anonymize('x');
const body = JSON.parse(fetchMock.mock.calls[0][1].body);
expect(body.response_format).toEqual({ type: 'json_object' });
});
it('parses JSON wrapped in a markdown code fence (lenient parsing)', async () => {
const fenced = '```json\n{"texte_anonymise":"[EMAIL_1]","mapping":{"[EMAIL_1]":"a@b.ch"}}\n```';
const fetchMock = vi.fn().mockResolvedValue({
ok: true,
json: async () => ({ choices: [{ message: { content: fenced } }] }),
});
vi.stubGlobal('fetch', fetchMock);
const r = await openAICompatibleProvider(opts).anonymize('a@b.ch');
expect(r).toEqual({ anon: '[EMAIL_1]', mapping: { '[EMAIL_1]': 'a@b.ch' }, legend: {} });
});
it('parses the model legende into result.legend, keeping only string values', async () => {
const fetchMock = mockFetchJson({
texte_anonymise: '[PER_1.NOM:M]',