From f5c9f0fbd4436bde549a0f139a2f2c02054aa5bb Mon Sep 17 00:00:00 2001 From: Mobiletic Date: Wed, 1 Jul 2026 09:43:19 +0100 Subject: [PATCH] feat: make openAICompatibleProvider work with Infomaniak out of the box (0.3.0) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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) --- CHANGELOG.md | 16 +++++++++++ README.md | 5 ++++ package.json | 2 +- src/providers/openai-compatible.ts | 45 ++++++++++++++++++++++++++---- test/openai-compatible.test.ts | 25 +++++++++++++++-- 5 files changed, 84 insertions(+), 9 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e9e9c7c..a1208f2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,22 @@ All notable changes to this project are documented here. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [0.3.0] - Unreleased + +### Changed + +- **`openAICompatibleProvider` now works with Infomaniak (and other open-model endpoints) out of the box.** + `response_format` is **omitted by default** instead of hard-coding `{ type: 'json_object' }`, which + current Infomaniak rejects (HTTP 422). Pass the new `responseFormat` option (e.g. `{ type: 'json_object' }` + or a `json_schema` object) for endpoints that support/require it. **Breaking** for endpoints that relied + on the previous forced `json_object`. +- JSON responses are now **parsed leniently** — a fenced JSON code block or surrounding prose is tolerated + (the outermost `{ … }` is extracted), so models without an enforced `response_format` don't cause + spurious parse failures. +- The default prompt gained a **COHÉRENCE** block (exact placeholder↔mapping-key identity, values are the + original data never another placeholder, strict `[TYPE_N…]` format, mask the value not the adjacent + label) to improve reliability across models. + ## [0.2.0] - Unreleased ### Added diff --git a/README.md b/README.md index ff56351..77570bf 100644 --- a/README.md +++ b/README.md @@ -80,6 +80,11 @@ giving up; 4xx and malformed responses are not retried. Tune with `timeoutMs` (p `retries` (default 1 → 2 attempts), and `retryDelayMs` (linear backoff, default 250). Worst-case latency is `(retries + 1) × timeoutMs`, so keep `retries` low on latency-sensitive paths. +It works with **Infomaniak** and other open-model endpoints out of the box: `response_format` is **omitted +by default** (Infomaniak rejects the legacy `{ type: 'json_object' }`), and responses are parsed leniently +(a fenced JSON code block or surrounding prose is tolerated). For endpoints that support it, opt in with +`responseFormat` — e.g. `{ type: 'json_object' }` or a `json_schema` object. + ### Streaming de-anonymization When you stream an LLM answer back to a user, restore real values without ever emitting a half-written diff --git a/package.json b/package.json index 900b465..cee3674 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@mobiletic/anonymizer", - "version": "0.2.0", + "version": "0.3.0", "description": "Framework-agnostic PII anonymization & pseudonymization: pluggable LLM detection for free-form PII (names, addresses) with a deterministic regex fallback, deterministic coreference, and streaming-safe de-anonymization.", "license": "MIT", "author": "Mobiletic", diff --git a/src/providers/openai-compatible.ts b/src/providers/openai-compatible.ts index 177d76d..bb19329 100644 --- a/src/providers/openai-compatible.ts +++ b/src/providers/openai-compatible.ts @@ -24,6 +24,12 @@ export const DEFAULT_SYSTEM_PROMPT = [ ' à sa signification complète en français. Ex.: {"PER":"Personne","NOM":"Nom de famille","M":"Masculin"}.', '- La légende sert à expliquer les placeholders à un autre modèle ; elle ne contient AUCUNE donnée réelle.', '', + 'COHÉRENCE (impératif) :', + '- Chaque clé de "mapping" est EXACTEMENT un placeholder présent dans "texte_anonymise" ; chaque placeholder du texte est une clé de "mapping".', + '- Les VALEURS de "mapping" sont les données ORIGINALES réelles, JAMAIS un autre placeholder.', + '- Format OBLIGATOIRE : [TYPE_N] ou [TYPE_N.ATTRIBUT:CONTEXTE] (TYPE en MAJUSCULES, N entier ; ex. [ORG_1.NOM:Entreprise], jamais [ORG:Entreprise]).', + '- Remplace UNIQUEMENT la valeur, pas le libellé voisin (ex. « numéro AVS 756… » → seul « 756… » devient un placeholder ; le mot « AVS » reste).', + '', 'RÈGLES:', '1. Coréférence: la MÊME personne garde le MÊME identifiant (PER_1) dans tout le texte.', '2. N’anonymise JAMAIS les termes pédagogiques/techniques (langages, concepts, titres de cours, fonctions).', @@ -49,6 +55,14 @@ export interface OpenAICompatibleOptions { retries?: number; /** Linear backoff between attempts in milliseconds (delay = attempt × this). Default 250. */ retryDelayMs?: number; + /** + * `response_format` to send with the request. **Omitted by default** — the + * Infomaniak API and several open-model endpoints reject the older + * `{ type: 'json_object' }`. Pass `{ type: 'json_object' }` for endpoints that + * require it, or a `{ type: 'json_schema', json_schema: … }` object. Regardless, + * responses are parsed leniently (markdown fences / surrounding prose tolerated). + */ + responseFormat?: Record; /** Override the system prompt (e.g. for another language or regulation). */ systemPrompt?: string; /** Extra instructions appended to the system prompt in batch mode. */ @@ -60,6 +74,22 @@ const sleep = (ms: number): Promise => new Promise((resolve) => setTimeout /** A transport error that the caller may retry (network / timeout / 429 / 5xx). */ class TransientError extends Error {} +/** + * Parse the model's JSON output leniently: tolerate a ```json fenced block and + * any prose around the object, keeping the outermost `{ … }`. Models without an + * enforced `response_format` often wrap their JSON, so this avoids spurious + * parse failures. + */ +function extractJson(content: string): unknown { + let t = content.trim(); + const fence = t.match(/```(?:json)?\s*([\s\S]*?)```/i); + if (fence) t = fence[1].trim(); + const i = t.indexOf('{'); + const j = t.lastIndexOf('}'); + if (i >= 0 && j > i) t = t.slice(i, j + 1); + return JSON.parse(t); +} + /** * Coerce the model's `legende` into a string→string map, lenient by design: a * missing or malformed legend yields `{}` rather than failing the anonymization @@ -86,14 +116,17 @@ const DEFAULT_BATCH_INSTRUCTIONS = (usedIds: string[]): string => /** * Build an {@link LlmProvider} backed by any OpenAI-compatible Chat Completions - * endpoint (OpenAI, Infomaniak, vLLM, Ollama, …). Uses `temperature: 0` and - * `response_format: json_object` for deterministic, parseable output, and throws - * on any failure so the {@link Anonymizer} falls back to its regex engine. + * endpoint (OpenAI, Infomaniak, vLLM, Ollama, …). Uses `temperature: 0` for + * deterministic output and parses the JSON response leniently. `response_format` + * is omitted by default (Infomaniak rejects the legacy `json_object`); pass + * `responseFormat` for endpoints that support/require it. Throws on any failure + * (so a configured {@link Anonymizer} fallback can take over, or it fails closed). */ export function openAICompatibleProvider(opts: OpenAICompatibleOptions): LlmProvider { const timeout = opts.timeoutMs ?? 3000; const retries = Math.max(0, opts.retries ?? 1); const retryDelayMs = opts.retryDelayMs ?? 250; + const responseFormat = opts.responseFormat; const systemPrompt = opts.systemPrompt ?? DEFAULT_SYSTEM_PROMPT; const batchInstructions = opts.batchInstructions ?? DEFAULT_BATCH_INSTRUCTIONS; @@ -114,7 +147,7 @@ export function openAICompatibleProvider(opts: OpenAICompatibleOptions): LlmProv body: JSON.stringify({ model: opts.model, temperature: 0, - response_format: { type: 'json_object' }, + ...(responseFormat ? { response_format: responseFormat } : {}), messages: [ { role: 'system', content: system }, { role: 'user', content: user }, @@ -157,7 +190,7 @@ export function openAICompatibleProvider(opts: OpenAICompatibleOptions): LlmProv async anonymize(text: string): Promise { const content = await chat(systemPrompt, text); - const parsed = JSON.parse(content) as { + const parsed = extractJson(content) as { texte_anonymise?: string; mapping?: Record; legende?: Record; @@ -178,7 +211,7 @@ export function openAICompatibleProvider(opts: OpenAICompatibleOptions): LlmProv ): Promise<{ segments: string[]; mapping: Record; legend: Record }> { const system = systemPrompt + batchInstructions(usedIds); const content = await chat(system, JSON.stringify({ segments: texts })); - const parsed = JSON.parse(content) as { + const parsed = extractJson(content) as { segments?: string[]; mapping?: Record; legende?: Record; diff --git a/test/openai-compatible.test.ts b/test/openai-compatible.test.ts index ddbf248..b0fd6bd 100644 --- a/test/openai-compatible.test.ts +++ b/test/openai-compatible.test.ts @@ -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]',