@startsimpli/llm 0.4.16 → 0.4.18
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +5 -3
- package/src/__tests__/openai-provider.test.ts +126 -0
- package/src/providers/openai.ts +31 -6
- package/src/translation/__fixtures__/golden-pairs.ts +243 -0
- package/src/translation/__tests__/batch-contract.test.ts +247 -0
- package/src/translation/__tests__/quality-gates.test.ts +338 -0
- package/src/translation/__tests__/route-confidentiality.test.ts +228 -0
- package/src/translation/__tests__/stub-provider.ts +118 -0
- package/src/translation/index.ts +784 -0
- package/src/translation/types.ts +300 -0
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ROUTING AND THE FAILURE PATH (bd startsim-ob4d, RED items 16–18).
|
|
3
|
+
*
|
|
4
|
+
* `startsim-bfem`: "Failure-path assertions matter as much as happy-path ones,
|
|
5
|
+
* because the failure path is where the confidentiality guarantee actually
|
|
6
|
+
* breaks." The named precedent is real and still in the estate — a Django
|
|
7
|
+
* handler that logs the model's raw response content on a JSON-parse failure.
|
|
8
|
+
* If translation reproduces that, the wall `startsim-hopl` sells is a comment.
|
|
9
|
+
*
|
|
10
|
+
* The three properties pinned here:
|
|
11
|
+
*
|
|
12
|
+
* 16. A malformed envelope is retried, then FAILS — and no response text and
|
|
13
|
+
* no client text reaches any log, on any path.
|
|
14
|
+
* 17. A `walled` route whose provider will not answer FAILS. It never crosses
|
|
15
|
+
* to a vendor under different retention terms. This is a deliberate
|
|
16
|
+
* override of `LLMService.getProvider()`'s "fall back to any available",
|
|
17
|
+
* whose `isAvailable()` is `Boolean(apiKey)` — and prod is on record
|
|
18
|
+
* holding an OpenAI key in `ANTHROPIC_API_KEY`, so a deployment that
|
|
19
|
+
* believes it is walled can route client text to another vendor with
|
|
20
|
+
* nothing saying so.
|
|
21
|
+
* 18. The service REFUSES TO BOOT on a model outside the deployment's
|
|
22
|
+
* allow-list. `claude-fable-5` is the named case: a Covered Model that
|
|
23
|
+
* cannot run under zero-retention terms, advertised by our own provider
|
|
24
|
+
* today. A live foot-gun, not a hypothetical.
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
import { describe, expect, it } from 'vitest';
|
|
28
|
+
|
|
29
|
+
import {
|
|
30
|
+
EnvelopeParseError,
|
|
31
|
+
TranslationConfigError,
|
|
32
|
+
WalledRouteUnavailableError,
|
|
33
|
+
createTranslationService,
|
|
34
|
+
parseTranslationEnvelope,
|
|
35
|
+
resolveTranslationRoute,
|
|
36
|
+
type TranslationRoute,
|
|
37
|
+
} from '../index';
|
|
38
|
+
import { GOLDEN_SETS, pairIn, segmentsOf } from '../__fixtures__/golden-pairs';
|
|
39
|
+
import { capturingLogger, stubProvider } from './stub-provider';
|
|
40
|
+
|
|
41
|
+
/** One locale is enough here — nothing in this file is language-sensitive. */
|
|
42
|
+
const SET = GOLDEN_SETS[0]!;
|
|
43
|
+
|
|
44
|
+
function walledRoute(overrides: Partial<TranslationRoute> = {}): TranslationRoute {
|
|
45
|
+
return {
|
|
46
|
+
posture: 'walled',
|
|
47
|
+
provider: 'stub',
|
|
48
|
+
model: 'stub-model',
|
|
49
|
+
allowedModels: ['stub-model'],
|
|
50
|
+
fallback: false,
|
|
51
|
+
...overrides,
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function request() {
|
|
56
|
+
return {
|
|
57
|
+
segments: segmentsOf(SET),
|
|
58
|
+
sourceLocale: SET.sourceLocale,
|
|
59
|
+
targetLocale: SET.targetLocale,
|
|
60
|
+
glossary: SET.glossary,
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
describe('a malformed envelope', () => {
|
|
65
|
+
/**
|
|
66
|
+
* Prose wrapped around the client's own sentence — what a model actually
|
|
67
|
+
* returns when it ignores the JSON instruction. The client text is IN here on
|
|
68
|
+
* purpose: it is the needle the log assertion searches for.
|
|
69
|
+
*/
|
|
70
|
+
const CLIENT_SENTENCE = pairIn(SET, 'glossary-term').sourceText;
|
|
71
|
+
const GARBAGE = `Sure! Here is the translation of "${CLIENT_SENTENCE}" — I hope it helps.`;
|
|
72
|
+
|
|
73
|
+
it('is retried before the batch is failed', async () => {
|
|
74
|
+
const provider = stubProvider(GARBAGE);
|
|
75
|
+
const service = createTranslationService(walledRoute(), {
|
|
76
|
+
provider,
|
|
77
|
+
maxEnvelopeRetries: 2,
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
await expect(service.translate(request())).rejects.toThrow(EnvelopeParseError);
|
|
81
|
+
// One original attempt plus the permitted retries — a parse failure is
|
|
82
|
+
// worth asking again for, once, with a correction prompt.
|
|
83
|
+
expect(provider.calls.length).toBeGreaterThan(1);
|
|
84
|
+
expect(provider.calls.length).toBeLessThanOrEqual(3);
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it('puts NO response text and NO client text in any log', async () => {
|
|
88
|
+
const logger = capturingLogger();
|
|
89
|
+
const provider = stubProvider(GARBAGE);
|
|
90
|
+
const service = createTranslationService(walledRoute(), {
|
|
91
|
+
provider,
|
|
92
|
+
logger,
|
|
93
|
+
maxEnvelopeRetries: 1,
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
await expect(service.translate(request())).rejects.toThrow(EnvelopeParseError);
|
|
97
|
+
|
|
98
|
+
// Asserted against the FIXTURE TEXT rather than by inspecting call sites,
|
|
99
|
+
// which is the only version of this check that cannot rot.
|
|
100
|
+
const logged = logger.all();
|
|
101
|
+
expect(logged).not.toContain(CLIENT_SENTENCE);
|
|
102
|
+
expect(logged).not.toContain(GARBAGE);
|
|
103
|
+
for (const pair of SET.pairs) {
|
|
104
|
+
if (pair.sourceText) expect(logged).not.toContain(pair.sourceText);
|
|
105
|
+
if (pair.targetText) expect(logged).not.toContain(pair.targetText);
|
|
106
|
+
}
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
it('carries no response text in the error it throws, either', async () => {
|
|
110
|
+
// An exception is a log line waiting to happen: it will be caught, printed,
|
|
111
|
+
// and shipped to an aggregator by code that has never read this file.
|
|
112
|
+
const provider = stubProvider(GARBAGE);
|
|
113
|
+
const service = createTranslationService(walledRoute(), {
|
|
114
|
+
provider,
|
|
115
|
+
maxEnvelopeRetries: 1,
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
await expect(service.translate(request())).rejects.toThrow(EnvelopeParseError);
|
|
119
|
+
await service.translate(request()).catch((error: unknown) => {
|
|
120
|
+
const text = `${(error as Error).message}\n${(error as Error).stack ?? ''}`;
|
|
121
|
+
expect(text).not.toContain(CLIENT_SENTENCE);
|
|
122
|
+
expect(text).not.toContain(GARBAGE);
|
|
123
|
+
});
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
it('rejects unparseable JSON as malformed rather than guessing', () => {
|
|
127
|
+
const parsed = parseTranslationEnvelope(GARBAGE, ['plain']);
|
|
128
|
+
|
|
129
|
+
expect(parsed.ok).toBe(false);
|
|
130
|
+
if (parsed.ok) return;
|
|
131
|
+
expect(parsed.reason).toBe('malformed');
|
|
132
|
+
});
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
describe('the walled posture', () => {
|
|
136
|
+
it('FAILS when the configured provider is unavailable, rather than falling back', async () => {
|
|
137
|
+
const provider = stubProvider('{"segments":[]}', { available: false });
|
|
138
|
+
const service = createTranslationService(walledRoute(), { provider });
|
|
139
|
+
|
|
140
|
+
await expect(service.translate(request())).rejects.toThrow(
|
|
141
|
+
WalledRouteUnavailableError,
|
|
142
|
+
);
|
|
143
|
+
// It must not even have tried: the refusal is the correct outcome, and the
|
|
144
|
+
// job is retried later against the SAME provider.
|
|
145
|
+
expect(provider.calls).toHaveLength(0);
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
it('refuses at BOOT to be configured with fallback enabled', () => {
|
|
149
|
+
// Posture is a property of the deployment and fallback is a property of the
|
|
150
|
+
// posture. `walled` + `fallback: true` is not a preference, it is a breach
|
|
151
|
+
// waiting for an outage.
|
|
152
|
+
expect(() =>
|
|
153
|
+
createTranslationService(walledRoute({ fallback: true }), {
|
|
154
|
+
provider: stubProvider('{}'),
|
|
155
|
+
}),
|
|
156
|
+
).toThrow(TranslationConfigError);
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
it('surfaces a provider refusal as a failure, never as an empty document', async () => {
|
|
160
|
+
const provider = stubProvider('{}', {
|
|
161
|
+
throws: new Error('provider refused'),
|
|
162
|
+
});
|
|
163
|
+
const service = createTranslationService(walledRoute(), { provider });
|
|
164
|
+
|
|
165
|
+
await expect(service.translate(request())).rejects.toThrow();
|
|
166
|
+
});
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
describe('the boot-time model allow-list', () => {
|
|
170
|
+
it('refuses to build a service whose model is outside the allow-list', () => {
|
|
171
|
+
expect(() =>
|
|
172
|
+
createTranslationService(
|
|
173
|
+
walledRoute({ model: 'claude-fable-5', allowedModels: ['stub-model'] }),
|
|
174
|
+
{ provider: stubProvider('{}') },
|
|
175
|
+
),
|
|
176
|
+
).toThrow(TranslationConfigError);
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
it('refuses a Covered Model on a walled deployment, by name', () => {
|
|
180
|
+
// Fail the boot, not the first client document.
|
|
181
|
+
expect(() =>
|
|
182
|
+
resolveTranslationRoute({
|
|
183
|
+
TRANSLATION_POSTURE: 'walled',
|
|
184
|
+
TRANSLATION_PROVIDER: 'anthropic',
|
|
185
|
+
TRANSLATION_MODEL: 'claude-fable-5',
|
|
186
|
+
}),
|
|
187
|
+
).toThrow(TranslationConfigError);
|
|
188
|
+
|
|
189
|
+
expect(() =>
|
|
190
|
+
resolveTranslationRoute({
|
|
191
|
+
TRANSLATION_POSTURE: 'walled',
|
|
192
|
+
TRANSLATION_PROVIDER: 'anthropic',
|
|
193
|
+
TRANSLATION_MODEL: 'claude-mythos-5',
|
|
194
|
+
}),
|
|
195
|
+
).toThrow(TranslationConfigError);
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
it('resolves a walled route with fallback OFF, whatever the environment asks for', () => {
|
|
199
|
+
const route = resolveTranslationRoute({
|
|
200
|
+
TRANSLATION_POSTURE: 'walled',
|
|
201
|
+
TRANSLATION_PROVIDER: 'anthropic',
|
|
202
|
+
TRANSLATION_MODEL: 'claude-sonnet-5',
|
|
203
|
+
TRANSLATION_ALLOWED_MODELS: 'claude-sonnet-5',
|
|
204
|
+
// Asked for, and ignored: the posture decides.
|
|
205
|
+
TRANSLATION_FALLBACK: 'true',
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
expect(route.posture).toBe('walled');
|
|
209
|
+
expect(route.fallback).toBe(false);
|
|
210
|
+
expect(route.model).toBe('claude-sonnet-5');
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
it('permits fallback on the open posture', () => {
|
|
214
|
+
// OGMC's marketing copy and MCR's artist metadata are not confidential in
|
|
215
|
+
// the sense the client's material is. Forcing the whole estate through the
|
|
216
|
+
// walled route would be cargo-culting.
|
|
217
|
+
const route = resolveTranslationRoute({
|
|
218
|
+
TRANSLATION_POSTURE: 'open',
|
|
219
|
+
TRANSLATION_PROVIDER: 'anthropic',
|
|
220
|
+
TRANSLATION_MODEL: 'claude-sonnet-5',
|
|
221
|
+
TRANSLATION_ALLOWED_MODELS: 'claude-sonnet-5',
|
|
222
|
+
TRANSLATION_FALLBACK: 'true',
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
expect(route.posture).toBe('open');
|
|
226
|
+
expect(route.fallback).toBe(true);
|
|
227
|
+
});
|
|
228
|
+
});
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A PURPOSE-BUILT provider stub for the translation suite.
|
|
3
|
+
*
|
|
4
|
+
* `MockLLMProvider` is deliberately not used here, and `startsim-bfem` says why:
|
|
5
|
+
* its behaviour is set through a module-global mutable (`let mockBehavior`, set
|
|
6
|
+
* via `setMockBehavior()`) carrying a single `customResponse` string. That is
|
|
7
|
+
* fine for one-response tests and wrong for these, which need a DIFFERENT
|
|
8
|
+
* envelope per call and must not leak state between parallel test files.
|
|
9
|
+
*
|
|
10
|
+
* The engine takes an `ILLMProvider` by injection, so a local stub costs one
|
|
11
|
+
* file and buys isolation. That is the right trade.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import type { ILLMProvider } from '../../providers/interface';
|
|
15
|
+
import type { GenerateOptions, LLMResponse } from '../../types';
|
|
16
|
+
import type { TranslationLogger } from '../types';
|
|
17
|
+
|
|
18
|
+
export interface RecordedCall {
|
|
19
|
+
userPrompt: string;
|
|
20
|
+
systemPrompt: string;
|
|
21
|
+
options?: GenerateOptions;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface StubProvider extends ILLMProvider {
|
|
25
|
+
/** Every call, in order, so a test can assert the BATCH count and the prompt. */
|
|
26
|
+
readonly calls: RecordedCall[];
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export interface StubProviderOptions {
|
|
30
|
+
name?: string;
|
|
31
|
+
model?: string;
|
|
32
|
+
/** `isAvailable()`. False models a provider that is down or unconfigured. */
|
|
33
|
+
available?: boolean;
|
|
34
|
+
/** Thrown instead of answering — a provider that refuses. */
|
|
35
|
+
throws?: Error;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* @param script What to return, per call. A string array is consumed in order
|
|
40
|
+
* and the last entry repeats; a function receives the 0-based call index.
|
|
41
|
+
*/
|
|
42
|
+
export function stubProvider(
|
|
43
|
+
script: string | string[] | ((callIndex: number, call: RecordedCall) => string),
|
|
44
|
+
options: StubProviderOptions = {},
|
|
45
|
+
): StubProvider {
|
|
46
|
+
const {
|
|
47
|
+
name = 'stub',
|
|
48
|
+
model = 'stub-model',
|
|
49
|
+
available = true,
|
|
50
|
+
throws,
|
|
51
|
+
} = options;
|
|
52
|
+
const calls: RecordedCall[] = [];
|
|
53
|
+
|
|
54
|
+
const pick = (index: number, call: RecordedCall): string => {
|
|
55
|
+
if (typeof script === 'function') return script(index, call);
|
|
56
|
+
if (typeof script === 'string') return script;
|
|
57
|
+
return script[Math.min(index, script.length - 1)] ?? '';
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
return {
|
|
61
|
+
name,
|
|
62
|
+
availableModels: [model],
|
|
63
|
+
defaultModel: model,
|
|
64
|
+
calls,
|
|
65
|
+
isAvailable: () => available,
|
|
66
|
+
async generate(
|
|
67
|
+
userPrompt: string,
|
|
68
|
+
systemPrompt: string,
|
|
69
|
+
generateOptions?: GenerateOptions,
|
|
70
|
+
): Promise<LLMResponse> {
|
|
71
|
+
const call: RecordedCall = { userPrompt, systemPrompt, options: generateOptions };
|
|
72
|
+
calls.push(call);
|
|
73
|
+
if (throws) throw throws;
|
|
74
|
+
return {
|
|
75
|
+
content: pick(calls.length - 1, call),
|
|
76
|
+
usage: { promptTokens: 0, completionTokens: 0, totalTokens: 0 },
|
|
77
|
+
model,
|
|
78
|
+
};
|
|
79
|
+
},
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export interface CapturingLogger extends TranslationLogger {
|
|
84
|
+
/** Every line and every piece of metadata, flattened for substring search. */
|
|
85
|
+
readonly written: string[];
|
|
86
|
+
/** One string carrying everything logged, for a single `not.toContain`. */
|
|
87
|
+
all(): string;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* A logger that keeps everything, so "no client text reaches a log" can be
|
|
92
|
+
* ASSERTED against the fixture text rather than checked by reading call sites.
|
|
93
|
+
*
|
|
94
|
+
* The metadata is serialised into the same haystack on purpose: the leak this
|
|
95
|
+
* guards against is a raw provider response passed as a structured field, which
|
|
96
|
+
* is exactly what the Django precedent at `messages/services/llm.py` does.
|
|
97
|
+
*/
|
|
98
|
+
export function capturingLogger(): CapturingLogger {
|
|
99
|
+
const written: string[] = [];
|
|
100
|
+
const record = (level: string) => (message: string, meta?: Record<string, unknown>) => {
|
|
101
|
+
written.push(`${level} ${message}`);
|
|
102
|
+
if (meta !== undefined) {
|
|
103
|
+
try {
|
|
104
|
+
written.push(JSON.stringify(meta));
|
|
105
|
+
} catch {
|
|
106
|
+
written.push(String(meta));
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
};
|
|
110
|
+
return {
|
|
111
|
+
written,
|
|
112
|
+
all: () => written.join('\n'),
|
|
113
|
+
debug: record('debug'),
|
|
114
|
+
info: record('info'),
|
|
115
|
+
warn: record('warn'),
|
|
116
|
+
error: record('error'),
|
|
117
|
+
};
|
|
118
|
+
}
|