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:
16
CHANGELOG.md
16
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
|
[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).
|
[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
|
## [0.2.0] - Unreleased
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|||||||
@@ -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
|
`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.
|
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
|
### Streaming de-anonymization
|
||||||
|
|
||||||
When you stream an LLM answer back to a user, restore real values without ever emitting a half-written
|
When you stream an LLM answer back to a user, restore real values without ever emitting a half-written
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@mobiletic/anonymizer",
|
"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.",
|
"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",
|
"license": "MIT",
|
||||||
"author": "Mobiletic",
|
"author": "Mobiletic",
|
||||||
|
|||||||
@@ -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"}.',
|
' à 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.',
|
'- 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:',
|
'RÈGLES:',
|
||||||
'1. Coréférence: la MÊME personne garde le MÊME identifiant (PER_1) dans tout le texte.',
|
'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).',
|
'2. N’anonymise JAMAIS les termes pédagogiques/techniques (langages, concepts, titres de cours, fonctions).',
|
||||||
@@ -49,6 +55,14 @@ export interface OpenAICompatibleOptions {
|
|||||||
retries?: number;
|
retries?: number;
|
||||||
/** Linear backoff between attempts in milliseconds (delay = attempt × this). Default 250. */
|
/** Linear backoff between attempts in milliseconds (delay = attempt × this). Default 250. */
|
||||||
retryDelayMs?: number;
|
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<string, unknown>;
|
||||||
/** Override the system prompt (e.g. for another language or regulation). */
|
/** Override the system prompt (e.g. for another language or regulation). */
|
||||||
systemPrompt?: string;
|
systemPrompt?: string;
|
||||||
/** Extra instructions appended to the system prompt in batch mode. */
|
/** Extra instructions appended to the system prompt in batch mode. */
|
||||||
@@ -60,6 +74,22 @@ const sleep = (ms: number): Promise<void> => new Promise((resolve) => setTimeout
|
|||||||
/** A transport error that the caller may retry (network / timeout / 429 / 5xx). */
|
/** A transport error that the caller may retry (network / timeout / 429 / 5xx). */
|
||||||
class TransientError extends Error {}
|
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
|
* Coerce the model's `legende` into a string→string map, lenient by design: a
|
||||||
* missing or malformed legend yields `{}` rather than failing the anonymization
|
* 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
|
* Build an {@link LlmProvider} backed by any OpenAI-compatible Chat Completions
|
||||||
* endpoint (OpenAI, Infomaniak, vLLM, Ollama, …). Uses `temperature: 0` and
|
* endpoint (OpenAI, Infomaniak, vLLM, Ollama, …). Uses `temperature: 0` for
|
||||||
* `response_format: json_object` for deterministic, parseable output, and throws
|
* deterministic output and parses the JSON response leniently. `response_format`
|
||||||
* on any failure so the {@link Anonymizer} falls back to its regex engine.
|
* 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 {
|
export function openAICompatibleProvider(opts: OpenAICompatibleOptions): LlmProvider {
|
||||||
const timeout = opts.timeoutMs ?? 3000;
|
const timeout = opts.timeoutMs ?? 3000;
|
||||||
const retries = Math.max(0, opts.retries ?? 1);
|
const retries = Math.max(0, opts.retries ?? 1);
|
||||||
const retryDelayMs = opts.retryDelayMs ?? 250;
|
const retryDelayMs = opts.retryDelayMs ?? 250;
|
||||||
|
const responseFormat = opts.responseFormat;
|
||||||
const systemPrompt = opts.systemPrompt ?? DEFAULT_SYSTEM_PROMPT;
|
const systemPrompt = opts.systemPrompt ?? DEFAULT_SYSTEM_PROMPT;
|
||||||
const batchInstructions = opts.batchInstructions ?? DEFAULT_BATCH_INSTRUCTIONS;
|
const batchInstructions = opts.batchInstructions ?? DEFAULT_BATCH_INSTRUCTIONS;
|
||||||
|
|
||||||
@@ -114,7 +147,7 @@ export function openAICompatibleProvider(opts: OpenAICompatibleOptions): LlmProv
|
|||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
model: opts.model,
|
model: opts.model,
|
||||||
temperature: 0,
|
temperature: 0,
|
||||||
response_format: { type: 'json_object' },
|
...(responseFormat ? { response_format: responseFormat } : {}),
|
||||||
messages: [
|
messages: [
|
||||||
{ role: 'system', content: system },
|
{ role: 'system', content: system },
|
||||||
{ role: 'user', content: user },
|
{ role: 'user', content: user },
|
||||||
@@ -157,7 +190,7 @@ export function openAICompatibleProvider(opts: OpenAICompatibleOptions): LlmProv
|
|||||||
|
|
||||||
async anonymize(text: string): Promise<AnonymizationResult> {
|
async anonymize(text: string): Promise<AnonymizationResult> {
|
||||||
const content = await chat(systemPrompt, text);
|
const content = await chat(systemPrompt, text);
|
||||||
const parsed = JSON.parse(content) as {
|
const parsed = extractJson(content) as {
|
||||||
texte_anonymise?: string;
|
texte_anonymise?: string;
|
||||||
mapping?: Record<string, string>;
|
mapping?: Record<string, string>;
|
||||||
legende?: Record<string, string>;
|
legende?: Record<string, string>;
|
||||||
@@ -178,7 +211,7 @@ export function openAICompatibleProvider(opts: OpenAICompatibleOptions): LlmProv
|
|||||||
): Promise<{ segments: string[]; mapping: Record<string, string>; legend: Record<string, string> }> {
|
): Promise<{ segments: string[]; mapping: Record<string, string>; legend: Record<string, string> }> {
|
||||||
const system = systemPrompt + batchInstructions(usedIds);
|
const system = systemPrompt + batchInstructions(usedIds);
|
||||||
const content = await chat(system, JSON.stringify({ segments: texts }));
|
const content = await chat(system, JSON.stringify({ segments: texts }));
|
||||||
const parsed = JSON.parse(content) as {
|
const parsed = extractJson(content) as {
|
||||||
segments?: string[];
|
segments?: string[];
|
||||||
mapping?: Record<string, string>;
|
mapping?: Record<string, string>;
|
||||||
legende?: Record<string, string>;
|
legende?: Record<string, string>;
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ describe('openAICompatibleProvider', () => {
|
|||||||
expect(openAICompatibleProvider({ ...opts, apiKey: '' }).isConfigured()).toBe(false);
|
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' } });
|
const fetchMock = mockFetchJson({ texte_anonymise: '[EMAIL_1]', mapping: { '[EMAIL_1]': 'a@b.ch' } });
|
||||||
vi.stubGlobal('fetch', fetchMock);
|
vi.stubGlobal('fetch', fetchMock);
|
||||||
|
|
||||||
@@ -31,10 +31,31 @@ describe('openAICompatibleProvider', () => {
|
|||||||
expect(url).toBe('https://api.example.com/v1/chat/completions');
|
expect(url).toBe('https://api.example.com/v1/chat/completions');
|
||||||
const body = JSON.parse(init.body);
|
const body = JSON.parse(init.body);
|
||||||
expect(body.temperature).toBe(0);
|
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');
|
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 () => {
|
it('parses the model legende into result.legend, keeping only string values', async () => {
|
||||||
const fetchMock = mockFetchJson({
|
const fetchMock = mockFetchJson({
|
||||||
texte_anonymise: '[PER_1.NOM:M]',
|
texte_anonymise: '[PER_1.NOM:M]',
|
||||||
|
|||||||
Reference in New Issue
Block a user