@open-mercato/ai-assistant 0.10.1-develop.7688.1.8bbd70dcdf → 0.10.1-develop.7690.1.9d41ec4115

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.
Files changed (26) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/dist/modules/ai_assistant/di.js +4 -0
  3. package/dist/modules/ai_assistant/di.js.map +2 -2
  4. package/dist/modules/ai_assistant/lib/decision-adapters/fixture.js +79 -0
  5. package/dist/modules/ai_assistant/lib/decision-adapters/fixture.js.map +7 -0
  6. package/dist/modules/ai_assistant/lib/decision-adapters/openrouter.js +226 -0
  7. package/dist/modules/ai_assistant/lib/decision-adapters/openrouter.js.map +7 -0
  8. package/dist/modules/ai_assistant/lib/decision-model-registry.js +66 -0
  9. package/dist/modules/ai_assistant/lib/decision-model-registry.js.map +7 -0
  10. package/dist/modules/ai_assistant/lib/decision-model-schemas.js +159 -0
  11. package/dist/modules/ai_assistant/lib/decision-model-schemas.js.map +7 -0
  12. package/dist/modules/ai_assistant/lib/decision-model-service.js +208 -0
  13. package/dist/modules/ai_assistant/lib/decision-model-service.js.map +7 -0
  14. package/package.json +6 -6
  15. package/src/modules/ai_assistant/di.ts +4 -0
  16. package/src/modules/ai_assistant/lib/__tests__/decision-fixture-adapter.test.ts +119 -0
  17. package/src/modules/ai_assistant/lib/__tests__/decision-model-registry.test.ts +203 -0
  18. package/src/modules/ai_assistant/lib/__tests__/decision-model-schemas-logging.test.ts +43 -0
  19. package/src/modules/ai_assistant/lib/__tests__/decision-model-schemas.test.ts +195 -0
  20. package/src/modules/ai_assistant/lib/__tests__/decision-model-service.test.ts +466 -0
  21. package/src/modules/ai_assistant/lib/__tests__/decision-openrouter-adapter.test.ts +321 -0
  22. package/src/modules/ai_assistant/lib/decision-adapters/fixture.ts +95 -0
  23. package/src/modules/ai_assistant/lib/decision-adapters/openrouter.ts +267 -0
  24. package/src/modules/ai_assistant/lib/decision-model-registry.ts +108 -0
  25. package/src/modules/ai_assistant/lib/decision-model-schemas.ts +224 -0
  26. package/src/modules/ai_assistant/lib/decision-model-service.ts +297 -0
@@ -0,0 +1,466 @@
1
+ import { createContainer } from 'awilix'
2
+ import type {
3
+ DecisionModelAdapter,
4
+ DecisionRequest,
5
+ DecisionResult,
6
+ } from '@open-mercato/shared/lib/ai/decision-model'
7
+ import { register } from '../../di'
8
+ import {
9
+ createDecisionModelRegistry,
10
+ type DecisionModelRegistry,
11
+ type DecisionModelResolution,
12
+ type DecisionModelUnavailableReason,
13
+ } from '../decision-model-registry'
14
+ import { DecisionModelError } from '../decision-model-schemas'
15
+ import {
16
+ DEFAULT_DECISION_TIMEOUT_MS,
17
+ MAX_DECISION_TIMEOUT_MS,
18
+ createDecisionModelService,
19
+ type DecisionModelService,
20
+ type DecisionModelServiceLogger,
21
+ } from '../decision-model-service'
22
+
23
+ const SECRET_STATE_VALUE = 'customer-secret-value-7781'
24
+ const SECRET_INSTRUCTIONS = 'Pick the routing lane for this confidential order'
25
+ const SECRET_SESSION_ID = 'session-opaque-4411'
26
+
27
+ function buildRequest(overrides: Partial<DecisionRequest> = {}): DecisionRequest {
28
+ return {
29
+ state: { note: SECRET_STATE_VALUE, lane: 'express' },
30
+ questions: {
31
+ lane: {
32
+ type: 'choice',
33
+ instructions: SECRET_INSTRUCTIONS,
34
+ options: [
35
+ { key: 'express', criteria: 'Fast lane' },
36
+ { key: 'standard', criteria: 'Regular lane' },
37
+ ],
38
+ },
39
+ },
40
+ sessionId: SECRET_SESSION_ID,
41
+ ...overrides,
42
+ }
43
+ }
44
+
45
+ function validResult(): DecisionResult {
46
+ return {
47
+ answers: {
48
+ lane: {
49
+ type: 'choice',
50
+ value: 'express',
51
+ probabilities: [
52
+ { key: 'express', probability: 0.8 },
53
+ { key: 'standard', probability: 0.2 },
54
+ ],
55
+ confidence: 0.8,
56
+ },
57
+ },
58
+ provider: 'stub',
59
+ model: 'stub-model',
60
+ usage: { inputTokens: 10, outputTokens: 2, costUsd: 0.001 },
61
+ durationMs: 1,
62
+ }
63
+ }
64
+
65
+ function stubAdapter(decide: DecisionModelAdapter['decide']): DecisionModelAdapter {
66
+ return { id: 'stub', isConfigured: () => true, decide }
67
+ }
68
+
69
+ function fakeRegistry(resolution: DecisionModelResolution): DecisionModelRegistry {
70
+ return {
71
+ register: () => undefined,
72
+ get: () => undefined,
73
+ list: () => [],
74
+ resolve: () => resolution,
75
+ }
76
+ }
77
+
78
+ function silentLogger(): DecisionModelServiceLogger & { info: jest.Mock; warn: jest.Mock } {
79
+ return { info: jest.fn(), warn: jest.fn() }
80
+ }
81
+
82
+ function serviceWith(
83
+ decide: DecisionModelAdapter['decide'],
84
+ extra: { env?: Record<string, string | undefined>; reportError?: jest.Mock; logger?: DecisionModelServiceLogger } = {},
85
+ ) {
86
+ const adapter = stubAdapter(decide)
87
+ const logger = extra.logger ?? silentLogger()
88
+ const reportError = extra.reportError ?? jest.fn()
89
+ const service = createDecisionModelService({
90
+ registry: fakeRegistry({ ok: true, adapter, modelId: 'stub-model' }),
91
+ env: extra.env ?? {},
92
+ logger,
93
+ reportError,
94
+ })
95
+ return { service, logger, reportError }
96
+ }
97
+
98
+ afterEach(() => {
99
+ jest.restoreAllMocks()
100
+ })
101
+
102
+ describe('decisionModelService.decide — success', () => {
103
+ it('answers through the real registry with the fixture adapter', async () => {
104
+ const env = {
105
+ OM_DECISION_FIXTURE_ENABLED: '1',
106
+ NODE_ENV: 'test',
107
+ OM_DECISION_PROVIDER: 'fixture',
108
+ OM_DECISION_MODEL: 'fixture-model',
109
+ }
110
+ const service = createDecisionModelService({
111
+ registry: createDecisionModelRegistry({ env }),
112
+ env,
113
+ logger: silentLogger(),
114
+ reportError: jest.fn(),
115
+ })
116
+ const outcome = await service.decide({
117
+ request: {
118
+ state: { lane: 'express' },
119
+ questions: {
120
+ lane: buildRequest().questions.lane,
121
+ urgency: { type: 'score', instructions: 'How urgent?', levels: ['low', 'mid', 'high'] },
122
+ refund: { type: 'binary', instructions: 'Refund?', criteria: { true: 'yes', false: 'no' } },
123
+ },
124
+ },
125
+ })
126
+ expect(outcome.ok).toBe(true)
127
+ if (!outcome.ok) return
128
+ expect(outcome.result.provider).toBe('fixture')
129
+ expect(outcome.result.model).toBe('fixture-model')
130
+ expect(outcome.result.answers.lane).toMatchObject({ type: 'choice', value: 'express' })
131
+ expect(outcome.result.answers.urgency).toMatchObject({ type: 'score', level: 0 })
132
+ expect(outcome.result.answers.refund).toEqual({ type: 'binary', probability: 0.5 })
133
+ expect(outcome.result.durationMs).toBeGreaterThanOrEqual(0)
134
+ })
135
+
136
+ it('measures durationMs end-to-end with the injected clock', async () => {
137
+ const times = [100, 175]
138
+ const service = createDecisionModelService({
139
+ registry: fakeRegistry({ ok: true, adapter: stubAdapter(async () => validResult()), modelId: 'stub-model' }),
140
+ env: {},
141
+ logger: silentLogger(),
142
+ reportError: jest.fn(),
143
+ now: () => times.shift() ?? 175,
144
+ })
145
+ const outcome = await service.decide({ request: buildRequest() })
146
+ expect(outcome.ok && outcome.result.durationMs).toBe(75)
147
+ })
148
+ })
149
+
150
+ describe('decisionModelService.decide — invalid_request and payload_too_large', () => {
151
+ it('rejects a request that fails the schema', async () => {
152
+ const adapterDecide = jest.fn(async () => validResult())
153
+ const { service } = serviceWith(adapterDecide)
154
+ const outcome = await service.decide({ request: buildRequest({ questions: {} }) })
155
+ expect(outcome).toEqual({ ok: false, error: { code: 'invalid_request', message: expect.any(String) } })
156
+ expect(adapterDecide).not.toHaveBeenCalled()
157
+ })
158
+
159
+ it('maps circular state to invalid_request', async () => {
160
+ const state: Record<string, unknown> = { name: 'loop' }
161
+ state.self = state
162
+ const { service } = serviceWith(async () => validResult())
163
+ const outcome = await service.decide({ request: buildRequest({ state }) })
164
+ expect(!outcome.ok && outcome.error.code).toBe('invalid_request')
165
+ })
166
+
167
+ it('maps BigInt state to invalid_request', async () => {
168
+ const { service } = serviceWith(async () => validResult())
169
+ const outcome = await service.decide({ request: buildRequest({ state: { amount: BigInt(10) } }) })
170
+ expect(!outcome.ok && outcome.error.code).toBe('invalid_request')
171
+ })
172
+
173
+ it('rejects payloads above OM_DECISION_MAX_INPUT_TOKENS', async () => {
174
+ const adapterDecide = jest.fn(async () => validResult())
175
+ const { service } = serviceWith(adapterDecide, { env: { OM_DECISION_MAX_INPUT_TOKENS: '5' } })
176
+ const outcome = await service.decide({ request: buildRequest() })
177
+ expect(!outcome.ok && outcome.error.code).toBe('payload_too_large')
178
+ expect(adapterDecide).not.toHaveBeenCalled()
179
+ })
180
+
181
+ it('falls back to the default cap for an invalid OM_DECISION_MAX_INPUT_TOKENS', async () => {
182
+ const { service } = serviceWith(async () => validResult(), { env: { OM_DECISION_MAX_INPUT_TOKENS: '-3' } })
183
+ const outcome = await service.decide({ request: buildRequest() })
184
+ expect(outcome.ok).toBe(true)
185
+ })
186
+ })
187
+
188
+ describe('decisionModelService.decide — unavailable', () => {
189
+ const reasons: DecisionModelUnavailableReason[] = [
190
+ 'not_configured',
191
+ 'unknown_provider',
192
+ 'credentials_missing',
193
+ 'model_missing',
194
+ 'fixture_disabled',
195
+ ]
196
+
197
+ it.each(reasons)('maps resolve reason %s to unavailable', async (reason) => {
198
+ const service = createDecisionModelService({
199
+ registry: fakeRegistry({ ok: false, reason }),
200
+ env: {},
201
+ logger: silentLogger(),
202
+ reportError: jest.fn(),
203
+ })
204
+ const outcome = await service.decide({ request: buildRequest() })
205
+ expect(outcome.ok).toBe(false)
206
+ if (outcome.ok) return
207
+ expect(outcome.error.code).toBe('unavailable')
208
+ expect(outcome.error.message).toContain(reason)
209
+ })
210
+ })
211
+
212
+ describe('decisionModelService.decide — adapter errors', () => {
213
+ it.each([
214
+ ['auth', 401],
215
+ ['insufficient_credits', 402],
216
+ ['rate_limited', 429],
217
+ ['unavailable', 404],
218
+ ['payload_too_large', 413],
219
+ ['invalid_request', 422],
220
+ ['provider_error', 503],
221
+ ] as const)('passes through DecisionModelError %s with its httpStatus', async (code, httpStatus) => {
222
+ const { service } = serviceWith(async () => {
223
+ throw new DecisionModelError(code, 'vendor said no', httpStatus)
224
+ })
225
+ const outcome = await service.decide({ request: buildRequest() })
226
+ expect(outcome.ok).toBe(false)
227
+ if (outcome.ok) return
228
+ expect(outcome.error.code).toBe(code)
229
+ expect(outcome.error.httpStatus).toBe(httpStatus)
230
+ expect(outcome.error.message).not.toContain('vendor said no')
231
+ })
232
+
233
+ it('maps a generic throw to provider_error and reports it', async () => {
234
+ const reportError = jest.fn()
235
+ const { service } = serviceWith(async () => {
236
+ throw new TypeError('socket hang up')
237
+ }, { reportError })
238
+ const outcome = await service.decide({ request: buildRequest() })
239
+ expect(!outcome.ok && outcome.error.code).toBe('provider_error')
240
+ expect(reportError).toHaveBeenCalledTimes(1)
241
+ expect(reportError).toHaveBeenCalledWith(expect.any(TypeError), {
242
+ module: 'ai_assistant',
243
+ code: 'ai_assistant.decision_provider_error',
244
+ })
245
+ })
246
+
247
+ it('maps malformed answers to invalid_response and reports it', async () => {
248
+ const reportError = jest.fn()
249
+ const { service } = serviceWith(async () => ({
250
+ ...validResult(),
251
+ answers: {
252
+ lane: {
253
+ type: 'choice',
254
+ value: 'unknown',
255
+ probabilities: [{ key: 'express', probability: 1 }],
256
+ confidence: 1,
257
+ },
258
+ },
259
+ }), { reportError })
260
+ const outcome = await service.decide({ request: buildRequest() })
261
+ expect(!outcome.ok && outcome.error.code).toBe('invalid_response')
262
+ expect(reportError).toHaveBeenCalledWith(expect.any(DecisionModelError), {
263
+ module: 'ai_assistant',
264
+ code: 'ai_assistant.decision_invalid_response',
265
+ })
266
+ })
267
+
268
+ it('does not call reportError for non-reported codes', async () => {
269
+ const reportError = jest.fn()
270
+ const { service } = serviceWith(async () => {
271
+ throw new DecisionModelError('rate_limited', 'slow down', 429)
272
+ }, { reportError })
273
+ await service.decide({ request: buildRequest() })
274
+ await service.decide({ request: buildRequest({ questions: {} }) })
275
+ expect(reportError).not.toHaveBeenCalled()
276
+ })
277
+ })
278
+
279
+ describe('decisionModelService.decide — timeout and abort', () => {
280
+ function captureTimeoutDelays(): number[] {
281
+ const delays: number[] = []
282
+ const realSetTimeout = global.setTimeout
283
+ jest.spyOn(global, 'setTimeout').mockImplementation(((handler: () => void, delay?: number) => {
284
+ delays.push(delay ?? 0)
285
+ return realSetTimeout(handler, delay)
286
+ }) as typeof setTimeout)
287
+ return delays
288
+ }
289
+
290
+ it.each([
291
+ [60000, MAX_DECISION_TIMEOUT_MS],
292
+ [0, DEFAULT_DECISION_TIMEOUT_MS],
293
+ [-5, DEFAULT_DECISION_TIMEOUT_MS],
294
+ [undefined, DEFAULT_DECISION_TIMEOUT_MS],
295
+ [Number.NaN, DEFAULT_DECISION_TIMEOUT_MS],
296
+ [1200, 1200],
297
+ ])('clamps timeoutMs %p to %p', async (timeoutMs, expected) => {
298
+ const delays = captureTimeoutDelays()
299
+ const { service } = serviceWith(async () => validResult())
300
+ const outcome = await service.decide({ request: buildRequest(), timeoutMs })
301
+ expect(outcome.ok).toBe(true)
302
+ expect(delays).toContain(expected)
303
+ })
304
+
305
+ it('ends a hanging adapter that ignores the signal with timeout', async () => {
306
+ const { service } = serviceWith(() => new Promise<DecisionResult>(() => undefined))
307
+ const outcome = await service.decide({ request: buildRequest(), timeoutMs: 20 })
308
+ expect(!outcome.ok && outcome.error.code).toBe('timeout')
309
+ })
310
+
311
+ it('reports timeout even when the adapter rethrows the abort as a generic error', async () => {
312
+ const { service } = serviceWith((_request, options) => new Promise<DecisionResult>((_resolve, reject) => {
313
+ options.signal.addEventListener('abort', () => reject(new Error('fetch aborted')))
314
+ }))
315
+ const outcome = await service.decide({ request: buildRequest(), timeoutMs: 20 })
316
+ expect(!outcome.ok && outcome.error.code).toBe('timeout')
317
+ })
318
+
319
+ it('maps a caller abort mid-flight to aborted', async () => {
320
+ const controller = new AbortController()
321
+ const { service } = serviceWith(() => {
322
+ setTimeout(() => controller.abort(new Error('user cancelled')), 5)
323
+ return new Promise<DecisionResult>(() => undefined)
324
+ })
325
+ const outcome = await service.decide({ request: buildRequest(), timeoutMs: 5000, signal: controller.signal })
326
+ expect(!outcome.ok && outcome.error.code).toBe('aborted')
327
+ })
328
+
329
+ it('returns aborted without calling the adapter when the caller signal is already aborted', async () => {
330
+ const controller = new AbortController()
331
+ controller.abort()
332
+ const adapterDecide = jest.fn(async () => validResult())
333
+ const { service } = serviceWith(adapterDecide)
334
+ const outcome = await service.decide({ request: buildRequest(), signal: controller.signal })
335
+ expect(!outcome.ok && outcome.error.code).toBe('aborted')
336
+ expect(adapterDecide).not.toHaveBeenCalled()
337
+ })
338
+
339
+ it('passes a combined signal and the model id to the adapter', async () => {
340
+ const adapterDecide = jest.fn<Promise<DecisionResult>, Parameters<DecisionModelAdapter['decide']>>(
341
+ async () => validResult(),
342
+ )
343
+ const { service } = serviceWith(adapterDecide)
344
+ await service.decide({ request: buildRequest() })
345
+ const options = adapterDecide.mock.calls[0]?.[1]
346
+ expect(options?.modelId).toBe('stub-model')
347
+ expect(options?.signal).toBeInstanceOf(AbortSignal)
348
+ })
349
+ })
350
+
351
+ describe('decisionModelService.decide — resilience and logging', () => {
352
+ it('still returns a result when the logger and reportError throw', async () => {
353
+ const throwingLogger: DecisionModelServiceLogger = {
354
+ info: () => {
355
+ throw new Error('logger down')
356
+ },
357
+ warn: () => {
358
+ throw new Error('logger down')
359
+ },
360
+ }
361
+ const throwingReport = jest.fn(() => {
362
+ throw new Error('telemetry down')
363
+ })
364
+ const okService = serviceWith(async () => validResult(), { logger: throwingLogger, reportError: throwingReport })
365
+ await expect(okService.service.decide({ request: buildRequest() })).resolves.toMatchObject({ ok: true })
366
+ const failService = serviceWith(async () => {
367
+ throw new Error('boom')
368
+ }, { logger: throwingLogger, reportError: throwingReport })
369
+ await expect(failService.service.decide({ request: buildRequest() })).resolves.toMatchObject({
370
+ ok: false,
371
+ error: { code: 'provider_error' },
372
+ })
373
+ expect(throwingReport).toHaveBeenCalled()
374
+ })
375
+
376
+ it('never resolves to a rejection even for a nonsense input', async () => {
377
+ const { service } = serviceWith(async () => validResult())
378
+ const outcome = await service.decide(undefined as never)
379
+ expect(!outcome.ok && outcome.error.code).toBe('invalid_request')
380
+ })
381
+
382
+ it('logs only safe fields and never the state, instructions or session id', async () => {
383
+ const logger = silentLogger()
384
+ const okRun = serviceWith(async () => validResult(), { logger })
385
+ await okRun.service.decide({ request: buildRequest() })
386
+ const failRun = serviceWith(async () => {
387
+ throw new Error(`vendor echoed ${SECRET_STATE_VALUE}`)
388
+ }, { logger })
389
+ await failRun.service.decide({ request: buildRequest() })
390
+
391
+ expect(logger.info).toHaveBeenCalledTimes(1)
392
+ expect(logger.warn).toHaveBeenCalledTimes(1)
393
+ const infoFields = logger.info.mock.calls[0]?.[1]
394
+ expect(Object.keys(infoFields).sort()).toEqual(
395
+ ['durationMs', 'model', 'outcomeCodes', 'provider', 'questionCount', 'usage'].sort(),
396
+ )
397
+ expect(infoFields).toMatchObject({ provider: 'stub', model: 'stub-model', questionCount: 1, outcomeCodes: ['ok'] })
398
+ expect(logger.warn.mock.calls[0]?.[1]).toMatchObject({ outcomeCodes: ['provider_error'] })
399
+
400
+ const serialized = JSON.stringify([...logger.info.mock.calls, ...logger.warn.mock.calls])
401
+ expect(serialized).not.toContain(SECRET_STATE_VALUE)
402
+ expect(serialized).not.toContain(SECRET_INSTRUCTIONS)
403
+ expect(serialized).not.toContain(SECRET_SESSION_ID)
404
+ expect(serialized).not.toContain('Fast lane')
405
+ })
406
+ })
407
+
408
+ describe('decisionModelService.getAvailability', () => {
409
+ it('reports the provider and model when resolvable', () => {
410
+ const service = createDecisionModelService({
411
+ registry: fakeRegistry({ ok: true, adapter: stubAdapter(async () => validResult()), modelId: 'stub-model' }),
412
+ env: {},
413
+ logger: silentLogger(),
414
+ })
415
+ expect(service.getAvailability({ moduleId: 'workflows' })).toEqual({
416
+ available: true,
417
+ provider: 'stub',
418
+ model: 'stub-model',
419
+ })
420
+ })
421
+
422
+ it('reports the reason when not resolvable', () => {
423
+ const service = createDecisionModelService({
424
+ registry: fakeRegistry({ ok: false, reason: 'credentials_missing' }),
425
+ env: {},
426
+ logger: silentLogger(),
427
+ })
428
+ expect(service.getAvailability()).toEqual({ available: false, reason: 'credentials_missing' })
429
+ })
430
+
431
+ it('never throws when the registry fails', () => {
432
+ const registry = fakeRegistry({ ok: false, reason: 'model_missing' })
433
+ registry.resolve = () => {
434
+ throw new Error('registry exploded')
435
+ }
436
+ const service = createDecisionModelService({ registry, env: {}, logger: silentLogger() })
437
+ expect(service.getAvailability()).toEqual({ available: false, reason: 'not_configured' })
438
+ })
439
+
440
+ it('uses the per-module model from the real registry', () => {
441
+ const env = {
442
+ OM_DECISION_FIXTURE_ENABLED: '1',
443
+ NODE_ENV: 'test',
444
+ OM_DECISION_PROVIDER: 'fixture',
445
+ OM_DECISION_MODEL: 'global-model',
446
+ OM_DECISION_WORKFLOWS_MODEL: 'workflow-model',
447
+ }
448
+ const service = createDecisionModelService({ registry: createDecisionModelRegistry({ env }), env, logger: silentLogger() })
449
+ expect(service.getAvailability({ moduleId: 'workflows' })).toEqual({
450
+ available: true,
451
+ provider: 'fixture',
452
+ model: 'workflow-model',
453
+ })
454
+ })
455
+ })
456
+
457
+ describe('ai_assistant DI registration', () => {
458
+ it('exposes decisionModelService with decide and getAvailability', () => {
459
+ const container = createContainer()
460
+ register(container)
461
+ const service = container.resolve<DecisionModelService>('decisionModelService')
462
+ expect(typeof service.decide).toBe('function')
463
+ expect(typeof service.getAvailability).toBe('function')
464
+ expect(container.resolve('decisionModelService')).toBe(service)
465
+ })
466
+ })