@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.
- package/.turbo/turbo-build.log +1 -1
- package/dist/modules/ai_assistant/di.js +4 -0
- package/dist/modules/ai_assistant/di.js.map +2 -2
- package/dist/modules/ai_assistant/lib/decision-adapters/fixture.js +79 -0
- package/dist/modules/ai_assistant/lib/decision-adapters/fixture.js.map +7 -0
- package/dist/modules/ai_assistant/lib/decision-adapters/openrouter.js +226 -0
- package/dist/modules/ai_assistant/lib/decision-adapters/openrouter.js.map +7 -0
- package/dist/modules/ai_assistant/lib/decision-model-registry.js +66 -0
- package/dist/modules/ai_assistant/lib/decision-model-registry.js.map +7 -0
- package/dist/modules/ai_assistant/lib/decision-model-schemas.js +159 -0
- package/dist/modules/ai_assistant/lib/decision-model-schemas.js.map +7 -0
- package/dist/modules/ai_assistant/lib/decision-model-service.js +208 -0
- package/dist/modules/ai_assistant/lib/decision-model-service.js.map +7 -0
- package/package.json +6 -6
- package/src/modules/ai_assistant/di.ts +4 -0
- package/src/modules/ai_assistant/lib/__tests__/decision-fixture-adapter.test.ts +119 -0
- package/src/modules/ai_assistant/lib/__tests__/decision-model-registry.test.ts +203 -0
- package/src/modules/ai_assistant/lib/__tests__/decision-model-schemas-logging.test.ts +43 -0
- package/src/modules/ai_assistant/lib/__tests__/decision-model-schemas.test.ts +195 -0
- package/src/modules/ai_assistant/lib/__tests__/decision-model-service.test.ts +466 -0
- package/src/modules/ai_assistant/lib/__tests__/decision-openrouter-adapter.test.ts +321 -0
- package/src/modules/ai_assistant/lib/decision-adapters/fixture.ts +95 -0
- package/src/modules/ai_assistant/lib/decision-adapters/openrouter.ts +267 -0
- package/src/modules/ai_assistant/lib/decision-model-registry.ts +108 -0
- package/src/modules/ai_assistant/lib/decision-model-schemas.ts +224 -0
- package/src/modules/ai_assistant/lib/decision-model-service.ts +297 -0
|
@@ -0,0 +1,321 @@
|
|
|
1
|
+
import type { EnvLookup } from '@open-mercato/shared/lib/ai/llm-provider'
|
|
2
|
+
import type { DecisionRequest } from '@open-mercato/shared/lib/ai/decision-model'
|
|
3
|
+
import { createOpenRouterDecisionAdapter } from '../decision-adapters/openrouter'
|
|
4
|
+
import { DecisionModelError } from '../decision-model-schemas'
|
|
5
|
+
|
|
6
|
+
const API_KEY = 'sk-or-test-key'
|
|
7
|
+
const BASE_ENV: EnvLookup = { OPENROUTER_API_KEY: API_KEY }
|
|
8
|
+
|
|
9
|
+
const originalFetch = global.fetch
|
|
10
|
+
let fetchMock: jest.Mock
|
|
11
|
+
|
|
12
|
+
function jsonResponse(status: number, body: unknown): Response {
|
|
13
|
+
return new Response(typeof body === 'string' ? body : JSON.stringify(body), {
|
|
14
|
+
status,
|
|
15
|
+
headers: { 'Content-Type': 'application/json' },
|
|
16
|
+
})
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function fullRequest(): DecisionRequest {
|
|
20
|
+
return {
|
|
21
|
+
state: { ticket: { subject: 'Refund please', amount: 42 } },
|
|
22
|
+
questions: {
|
|
23
|
+
route: {
|
|
24
|
+
type: 'choice',
|
|
25
|
+
instructions: 'Pick the queue',
|
|
26
|
+
options: [
|
|
27
|
+
{ key: '10', criteria: 'Ten queue' },
|
|
28
|
+
{ key: '2', criteria: 'Two queue' },
|
|
29
|
+
{ key: 'billing', criteria: 'Billing queue' },
|
|
30
|
+
],
|
|
31
|
+
},
|
|
32
|
+
urgency: {
|
|
33
|
+
type: 'score',
|
|
34
|
+
instructions: 'Rate urgency',
|
|
35
|
+
levels: ['low', 'medium', 'high'],
|
|
36
|
+
},
|
|
37
|
+
spam: {
|
|
38
|
+
type: 'binary',
|
|
39
|
+
instructions: 'Is it spam?',
|
|
40
|
+
criteria: { true: 'Spam', false: 'Legit' },
|
|
41
|
+
},
|
|
42
|
+
},
|
|
43
|
+
sessionId: 'wf-instance-1',
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function successBody(overrides: Record<string, unknown> = {}) {
|
|
48
|
+
return {
|
|
49
|
+
model: 'typesafe/jev-1.13',
|
|
50
|
+
answers: {
|
|
51
|
+
route: {
|
|
52
|
+
choice: 'billing',
|
|
53
|
+
confidence: 0.7,
|
|
54
|
+
probabilities: { billing: 0.7, '2': 0.2, '10': 0.1 },
|
|
55
|
+
},
|
|
56
|
+
urgency: {
|
|
57
|
+
score: 1.5,
|
|
58
|
+
confidence: 0.6,
|
|
59
|
+
probabilities: [0.1, 0.3, 0.6],
|
|
60
|
+
legend: ['low', 'medium', 'high'],
|
|
61
|
+
},
|
|
62
|
+
spam: { noul: 0.05 },
|
|
63
|
+
...overrides,
|
|
64
|
+
},
|
|
65
|
+
usage: { input_tokens: 120, output_tokens: 3, cost: 0.0004 },
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
async function decide(request: DecisionRequest = fullRequest(), env: EnvLookup = BASE_ENV) {
|
|
70
|
+
const adapter = createOpenRouterDecisionAdapter()
|
|
71
|
+
return adapter.decide(request, { modelId: 'typesafe/jev-1.13', env, signal: new AbortController().signal })
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
async function decideError(request?: DecisionRequest): Promise<DecisionModelError> {
|
|
75
|
+
try {
|
|
76
|
+
await decide(request)
|
|
77
|
+
} catch (error) {
|
|
78
|
+
if (error instanceof DecisionModelError) return error
|
|
79
|
+
throw error
|
|
80
|
+
}
|
|
81
|
+
throw new Error('[internal] expected decide to throw')
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function sentInit(): RequestInit {
|
|
85
|
+
const call = fetchMock.mock.calls[0]
|
|
86
|
+
return call[1] as RequestInit
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function sentUrl(): string {
|
|
90
|
+
return String(fetchMock.mock.calls[0][0])
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function sentBodyText(): string {
|
|
94
|
+
return String(sentInit().body)
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
beforeEach(() => {
|
|
98
|
+
fetchMock = jest.fn()
|
|
99
|
+
global.fetch = fetchMock as unknown as typeof fetch
|
|
100
|
+
})
|
|
101
|
+
|
|
102
|
+
afterEach(() => {
|
|
103
|
+
global.fetch = originalFetch
|
|
104
|
+
})
|
|
105
|
+
|
|
106
|
+
describe('createOpenRouterDecisionAdapter', () => {
|
|
107
|
+
describe('isConfigured', () => {
|
|
108
|
+
const adapter = createOpenRouterDecisionAdapter()
|
|
109
|
+
|
|
110
|
+
it('exposes the openrouter id', () => {
|
|
111
|
+
expect(adapter.id).toBe('openrouter')
|
|
112
|
+
})
|
|
113
|
+
|
|
114
|
+
it('is configured when OPENROUTER_API_KEY is set', () => {
|
|
115
|
+
expect(adapter.isConfigured({ OPENROUTER_API_KEY: API_KEY })).toBe(true)
|
|
116
|
+
})
|
|
117
|
+
|
|
118
|
+
it('is not configured when the key is missing or blank', () => {
|
|
119
|
+
expect(adapter.isConfigured({})).toBe(false)
|
|
120
|
+
expect(adapter.isConfigured({ OPENROUTER_API_KEY: ' ' })).toBe(false)
|
|
121
|
+
})
|
|
122
|
+
})
|
|
123
|
+
|
|
124
|
+
describe('request mapping', () => {
|
|
125
|
+
beforeEach(() => {
|
|
126
|
+
fetchMock.mockResolvedValue(jsonResponse(200, successBody()))
|
|
127
|
+
})
|
|
128
|
+
|
|
129
|
+
it('posts to the default base URL with the bearer key', async () => {
|
|
130
|
+
await decide()
|
|
131
|
+
expect(sentUrl()).toBe('https://openrouter.ai/api/alpha/decisions')
|
|
132
|
+
const init = sentInit()
|
|
133
|
+
expect(init.method).toBe('POST')
|
|
134
|
+
expect(init.headers).toEqual({
|
|
135
|
+
'Content-Type': 'application/json',
|
|
136
|
+
Authorization: `Bearer ${API_KEY}`,
|
|
137
|
+
})
|
|
138
|
+
expect(init.signal).toBeInstanceOf(AbortSignal)
|
|
139
|
+
})
|
|
140
|
+
|
|
141
|
+
it('uses OM_DECISION_OPENROUTER_BASE_URL with trailing slashes stripped', async () => {
|
|
142
|
+
await decide(fullRequest(), { ...BASE_ENV, OM_DECISION_OPENROUTER_BASE_URL: ' https://proxy.test/api/ ' })
|
|
143
|
+
expect(sentUrl()).toBe('https://proxy.test/api/alpha/decisions')
|
|
144
|
+
})
|
|
145
|
+
|
|
146
|
+
it('ignores OPENROUTER_BASE_URL (the LLM preset base)', async () => {
|
|
147
|
+
await decide(fullRequest(), { ...BASE_ENV, OPENROUTER_BASE_URL: 'https://example.test/api/v1' })
|
|
148
|
+
expect(sentUrl()).toBe('https://openrouter.ai/api/alpha/decisions')
|
|
149
|
+
})
|
|
150
|
+
|
|
151
|
+
it('keeps choice criteria in option insertion order, including integer-like keys', async () => {
|
|
152
|
+
await decide()
|
|
153
|
+
expect(sentBodyText()).toContain('"criteria":{"10":"Ten queue","2":"Two queue","billing":"Billing queue"}')
|
|
154
|
+
})
|
|
155
|
+
|
|
156
|
+
it('maps all three question types, model, state and session_id', async () => {
|
|
157
|
+
await decide()
|
|
158
|
+
const body = JSON.parse(sentBodyText())
|
|
159
|
+
expect(body.model).toBe('typesafe/jev-1.13')
|
|
160
|
+
expect(body.state).toEqual({ ticket: { subject: 'Refund please', amount: 42 } })
|
|
161
|
+
expect(body.session_id).toBe('wf-instance-1')
|
|
162
|
+
expect(body.questions.route.type).toBe('choice')
|
|
163
|
+
expect(body.questions.route.instructions).toBe('Pick the queue')
|
|
164
|
+
expect(body.questions.urgency).toEqual({
|
|
165
|
+
type: 'score',
|
|
166
|
+
instructions: 'Rate urgency',
|
|
167
|
+
criteria: ['low', 'medium', 'high'],
|
|
168
|
+
})
|
|
169
|
+
expect(body.questions.spam).toEqual({
|
|
170
|
+
type: 'noul',
|
|
171
|
+
instructions: 'Is it spam?',
|
|
172
|
+
criteria: { true: 'Spam', false: 'Legit' },
|
|
173
|
+
})
|
|
174
|
+
expect(body).not.toHaveProperty('user')
|
|
175
|
+
})
|
|
176
|
+
|
|
177
|
+
it('omits session_id when the request has none', async () => {
|
|
178
|
+
const request = fullRequest()
|
|
179
|
+
delete request.sessionId
|
|
180
|
+
await decide(request)
|
|
181
|
+
const body = JSON.parse(sentBodyText())
|
|
182
|
+
expect(body).not.toHaveProperty('session_id')
|
|
183
|
+
expect(body).not.toHaveProperty('user')
|
|
184
|
+
})
|
|
185
|
+
})
|
|
186
|
+
|
|
187
|
+
describe('response mapping', () => {
|
|
188
|
+
it('maps choice probabilities in request order', async () => {
|
|
189
|
+
fetchMock.mockResolvedValue(jsonResponse(200, successBody()))
|
|
190
|
+
const result = await decide()
|
|
191
|
+
expect(result.answers.route).toEqual({
|
|
192
|
+
type: 'choice',
|
|
193
|
+
value: 'billing',
|
|
194
|
+
probabilities: [
|
|
195
|
+
{ key: '10', probability: 0.1 },
|
|
196
|
+
{ key: '2', probability: 0.2 },
|
|
197
|
+
{ key: 'billing', probability: 0.7 },
|
|
198
|
+
],
|
|
199
|
+
confidence: 0.7,
|
|
200
|
+
})
|
|
201
|
+
})
|
|
202
|
+
|
|
203
|
+
it('maps score probabilities given as an array', async () => {
|
|
204
|
+
fetchMock.mockResolvedValue(jsonResponse(200, successBody()))
|
|
205
|
+
const result = await decide()
|
|
206
|
+
const answer = result.answers.urgency
|
|
207
|
+
if (answer.type !== 'score') throw new Error('[internal] expected a score answer')
|
|
208
|
+
expect(answer.level).toBe(2)
|
|
209
|
+
expect(answer.value).toBeCloseTo(1.5)
|
|
210
|
+
expect(answer.probabilities).toEqual([
|
|
211
|
+
{ level: 0, probability: 0.1 },
|
|
212
|
+
{ level: 1, probability: 0.3 },
|
|
213
|
+
{ level: 2, probability: 0.6 },
|
|
214
|
+
])
|
|
215
|
+
expect(answer.confidence).toBe(0.6)
|
|
216
|
+
})
|
|
217
|
+
|
|
218
|
+
it('maps score probabilities given as an object keyed by level index', async () => {
|
|
219
|
+
fetchMock.mockResolvedValue(jsonResponse(200, successBody({
|
|
220
|
+
urgency: { score: 0.4, confidence: 0.7, probabilities: { '2': 0.1, '0': 0.7, '1': 0.2 }, legend: {} },
|
|
221
|
+
})))
|
|
222
|
+
const result = await decide()
|
|
223
|
+
const answer = result.answers.urgency
|
|
224
|
+
if (answer.type !== 'score') throw new Error('[internal] expected a score answer')
|
|
225
|
+
expect(answer.level).toBe(0)
|
|
226
|
+
expect(answer.probabilities).toEqual([
|
|
227
|
+
{ level: 0, probability: expect.closeTo(0.7) },
|
|
228
|
+
{ level: 1, probability: expect.closeTo(0.2) },
|
|
229
|
+
{ level: 2, probability: expect.closeTo(0.1) },
|
|
230
|
+
])
|
|
231
|
+
})
|
|
232
|
+
|
|
233
|
+
it('maps noul to a binary probability', async () => {
|
|
234
|
+
fetchMock.mockResolvedValue(jsonResponse(200, successBody()))
|
|
235
|
+
const result = await decide()
|
|
236
|
+
expect(result.answers.spam).toEqual({ type: 'binary', probability: 0.05 })
|
|
237
|
+
})
|
|
238
|
+
|
|
239
|
+
it('maps provider, model and usage', async () => {
|
|
240
|
+
fetchMock.mockResolvedValue(jsonResponse(200, successBody()))
|
|
241
|
+
const result = await decide()
|
|
242
|
+
expect(result.provider).toBe('openrouter')
|
|
243
|
+
expect(result.model).toBe('typesafe/jev-1.13')
|
|
244
|
+
expect(result.usage).toEqual({ inputTokens: 120, outputTokens: 3, costUsd: 0.0004 })
|
|
245
|
+
expect(result.durationMs).toBeGreaterThanOrEqual(0)
|
|
246
|
+
})
|
|
247
|
+
|
|
248
|
+
it('returns null usage and the requested model when the vendor omits them', async () => {
|
|
249
|
+
const body: Record<string, unknown> = successBody()
|
|
250
|
+
delete body.usage
|
|
251
|
+
delete body.model
|
|
252
|
+
fetchMock.mockResolvedValue(jsonResponse(200, body))
|
|
253
|
+
const result = await decide()
|
|
254
|
+
expect(result.model).toBe('typesafe/jev-1.13')
|
|
255
|
+
expect(result.usage).toEqual({ inputTokens: null, outputTokens: null, costUsd: null })
|
|
256
|
+
})
|
|
257
|
+
|
|
258
|
+
it('rejects an unknown key in choice probabilities as invalid_response', async () => {
|
|
259
|
+
fetchMock.mockResolvedValue(jsonResponse(200, successBody({
|
|
260
|
+
route: {
|
|
261
|
+
choice: 'billing',
|
|
262
|
+
confidence: 0.7,
|
|
263
|
+
probabilities: { billing: 0.7, '2': 0.2, '10': 0.05, shipping: 0.05 },
|
|
264
|
+
},
|
|
265
|
+
})))
|
|
266
|
+
const error = await decideError()
|
|
267
|
+
expect(error.code).toBe('invalid_response')
|
|
268
|
+
})
|
|
269
|
+
|
|
270
|
+
it('rejects an answer whose shape cannot be mapped as invalid_response', async () => {
|
|
271
|
+
fetchMock.mockResolvedValue(jsonResponse(200, successBody({ spam: { choice: 'yes' } })))
|
|
272
|
+
const error = await decideError()
|
|
273
|
+
expect(error.code).toBe('invalid_response')
|
|
274
|
+
})
|
|
275
|
+
|
|
276
|
+
it('treats an unparseable 2xx body as provider_error', async () => {
|
|
277
|
+
fetchMock.mockResolvedValue(new Response('<html>oops</html>', { status: 200 }))
|
|
278
|
+
const error = await decideError()
|
|
279
|
+
expect(error.code).toBe('provider_error')
|
|
280
|
+
expect(error.httpStatus).toBeUndefined()
|
|
281
|
+
})
|
|
282
|
+
})
|
|
283
|
+
|
|
284
|
+
describe('HTTP error mapping', () => {
|
|
285
|
+
it.each([
|
|
286
|
+
[400, 'invalid_request'],
|
|
287
|
+
[401, 'auth'],
|
|
288
|
+
[402, 'insufficient_credits'],
|
|
289
|
+
[403, 'auth'],
|
|
290
|
+
[404, 'unavailable'],
|
|
291
|
+
[413, 'payload_too_large'],
|
|
292
|
+
[422, 'invalid_request'],
|
|
293
|
+
[429, 'rate_limited'],
|
|
294
|
+
[500, 'provider_error'],
|
|
295
|
+
[524, 'timeout'],
|
|
296
|
+
[529, 'provider_error'],
|
|
297
|
+
])('maps HTTP %i to %s', async (status, code) => {
|
|
298
|
+
fetchMock.mockResolvedValue(jsonResponse(status, { error: { code: 'vendor_code', message: 'secret vendor detail' } }))
|
|
299
|
+
const error = await decideError()
|
|
300
|
+
expect(error.code).toBe(code)
|
|
301
|
+
expect(error.httpStatus).toBe(status)
|
|
302
|
+
expect(error.message).toContain('vendor_code')
|
|
303
|
+
expect(error.message).not.toContain('secret vendor detail')
|
|
304
|
+
expect(error.message).not.toContain(API_KEY)
|
|
305
|
+
expect(error.message).not.toContain('Refund please')
|
|
306
|
+
})
|
|
307
|
+
|
|
308
|
+
it('tolerates an unparseable error body', async () => {
|
|
309
|
+
fetchMock.mockResolvedValue(new Response('not json', { status: 503 }))
|
|
310
|
+
const error = await decideError()
|
|
311
|
+
expect(error.code).toBe('provider_error')
|
|
312
|
+
expect(error.httpStatus).toBe(503)
|
|
313
|
+
})
|
|
314
|
+
})
|
|
315
|
+
|
|
316
|
+
it('rethrows fetch rejections unchanged', async () => {
|
|
317
|
+
const abortError = new DOMException('The operation was aborted.', 'AbortError')
|
|
318
|
+
fetchMock.mockRejectedValue(abortError)
|
|
319
|
+
await expect(decide()).rejects.toBe(abortError)
|
|
320
|
+
})
|
|
321
|
+
})
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
DecisionModelAdapter,
|
|
3
|
+
DecisionQuestion,
|
|
4
|
+
DecisionRequest,
|
|
5
|
+
DecisionResult,
|
|
6
|
+
} from '@open-mercato/shared/lib/ai/decision-model'
|
|
7
|
+
import { normalizeDecisionAnswers, type RawDecisionAnswer } from '../decision-model-schemas'
|
|
8
|
+
|
|
9
|
+
function escapeRegExp(value: string): string {
|
|
10
|
+
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function containsWholeWord(haystack: string, word: string): boolean {
|
|
14
|
+
const pattern = new RegExp(`(^|[^A-Za-z0-9_-])${escapeRegExp(word)}($|[^A-Za-z0-9_-])`, 'i')
|
|
15
|
+
return pattern.test(haystack)
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function serializeState(state: unknown): string {
|
|
19
|
+
return JSON.stringify(state) ?? ''
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function answerChoice(
|
|
23
|
+
question: Extract<DecisionQuestion, { type: 'choice' }>,
|
|
24
|
+
serializedState: string,
|
|
25
|
+
): RawDecisionAnswer {
|
|
26
|
+
const matched = question.options.find((option) => containsWholeWord(serializedState, option.key))
|
|
27
|
+
if (matched) {
|
|
28
|
+
return {
|
|
29
|
+
type: 'choice',
|
|
30
|
+
value: matched.key,
|
|
31
|
+
probabilities: question.options.map((option) => ({
|
|
32
|
+
key: option.key,
|
|
33
|
+
probability: option.key === matched.key ? 1 : 0,
|
|
34
|
+
})),
|
|
35
|
+
confidence: 1,
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
const uniform = 1 / question.options.length
|
|
39
|
+
return {
|
|
40
|
+
type: 'choice',
|
|
41
|
+
value: question.options[0].key,
|
|
42
|
+
probabilities: question.options.map((option) => ({ key: option.key, probability: uniform })),
|
|
43
|
+
confidence: uniform,
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function answerScore(question: Extract<DecisionQuestion, { type: 'score' }>): RawDecisionAnswer {
|
|
48
|
+
const uniform = 1 / question.levels.length
|
|
49
|
+
return {
|
|
50
|
+
type: 'score',
|
|
51
|
+
probabilities: question.levels.map((_, level) => ({ level, probability: uniform })),
|
|
52
|
+
confidence: uniform,
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function answerQuestion(question: DecisionQuestion, serializedState: string): RawDecisionAnswer {
|
|
57
|
+
switch (question.type) {
|
|
58
|
+
case 'choice':
|
|
59
|
+
return answerChoice(question, serializedState)
|
|
60
|
+
case 'score':
|
|
61
|
+
return answerScore(question)
|
|
62
|
+
case 'binary':
|
|
63
|
+
return { type: 'binary', probability: 0.5 }
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Deterministic, vendor-free decision adapter for tests and local
|
|
69
|
+
* development (spec D9). Registration is gated by the registry, not here.
|
|
70
|
+
*/
|
|
71
|
+
export function createFixtureDecisionAdapter(): DecisionModelAdapter {
|
|
72
|
+
return {
|
|
73
|
+
id: 'fixture',
|
|
74
|
+
isConfigured: () => true,
|
|
75
|
+
async decide(request: DecisionRequest, options): Promise<DecisionResult> {
|
|
76
|
+
if (options.signal.aborted) {
|
|
77
|
+
throw options.signal.reason
|
|
78
|
+
}
|
|
79
|
+
const startedAt = Date.now()
|
|
80
|
+
const serializedState = serializeState(request.state)
|
|
81
|
+
const raw: Record<string, RawDecisionAnswer> = {}
|
|
82
|
+
for (const [questionName, question] of Object.entries(request.questions)) {
|
|
83
|
+
raw[questionName] = answerQuestion(question, serializedState)
|
|
84
|
+
}
|
|
85
|
+
const answers = normalizeDecisionAnswers(request, raw)
|
|
86
|
+
return {
|
|
87
|
+
answers,
|
|
88
|
+
provider: 'fixture',
|
|
89
|
+
model: options.modelId,
|
|
90
|
+
usage: { inputTokens: null, outputTokens: null, costUsd: null },
|
|
91
|
+
durationMs: Date.now() - startedAt,
|
|
92
|
+
}
|
|
93
|
+
},
|
|
94
|
+
}
|
|
95
|
+
}
|
|
@@ -0,0 +1,267 @@
|
|
|
1
|
+
import { z } from 'zod'
|
|
2
|
+
import type { EnvLookup } from '@open-mercato/shared/lib/ai/llm-provider'
|
|
3
|
+
import type {
|
|
4
|
+
DecisionErrorCode,
|
|
5
|
+
DecisionModelAdapter,
|
|
6
|
+
DecisionQuestion,
|
|
7
|
+
DecisionRequest,
|
|
8
|
+
DecisionResult,
|
|
9
|
+
} from '@open-mercato/shared/lib/ai/decision-model'
|
|
10
|
+
import { DecisionModelError, normalizeDecisionAnswers, type RawDecisionAnswer } from '../decision-model-schemas'
|
|
11
|
+
|
|
12
|
+
const DEFAULT_BASE_URL = 'https://openrouter.ai/api'
|
|
13
|
+
const API_KEY_ENV = 'OPENROUTER_API_KEY'
|
|
14
|
+
const BASE_URL_ENV = 'OM_DECISION_OPENROUTER_BASE_URL'
|
|
15
|
+
const VENDOR_ERROR_CODE_PATTERN = /^[A-Za-z0-9_.-]{1,64}$/
|
|
16
|
+
|
|
17
|
+
const wireConfidenceSchema = z.number().nullish()
|
|
18
|
+
|
|
19
|
+
const wireChoiceAnswerSchema = z.object({
|
|
20
|
+
choice: z.string(),
|
|
21
|
+
confidence: wireConfidenceSchema,
|
|
22
|
+
probabilities: z.record(z.string(), z.number()),
|
|
23
|
+
})
|
|
24
|
+
|
|
25
|
+
const wireScoreAnswerSchema = z.object({
|
|
26
|
+
confidence: wireConfidenceSchema,
|
|
27
|
+
probabilities: z.union([z.array(z.number()), z.record(z.string(), z.number())]),
|
|
28
|
+
})
|
|
29
|
+
|
|
30
|
+
const wireNoulAnswerSchema = z.object({
|
|
31
|
+
noul: z.number(),
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
const wireResponseSchema = z.object({
|
|
35
|
+
model: z.string().nullish(),
|
|
36
|
+
answers: z.record(z.string(), z.unknown()),
|
|
37
|
+
usage: z.record(z.string(), z.unknown()).nullish(),
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
function readEnv(env: EnvLookup | undefined, key: string): string | undefined {
|
|
41
|
+
const value = (env ?? process.env)[key]
|
|
42
|
+
if (typeof value !== 'string') return undefined
|
|
43
|
+
const trimmed = value.trim()
|
|
44
|
+
return trimmed.length > 0 ? trimmed : undefined
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function resolveBaseUrl(env: EnvLookup | undefined): string {
|
|
48
|
+
const configured = readEnv(env, BASE_URL_ENV) ?? DEFAULT_BASE_URL
|
|
49
|
+
return configured.replace(/\/+$/, '')
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function serializeOrderedObject(entries: Array<[string, string]>): string {
|
|
53
|
+
return `{${entries.map(([key, serializedValue]) => `${JSON.stringify(key)}:${serializedValue}`).join(',')}}`
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function serializeCriteria(question: DecisionQuestion): string {
|
|
57
|
+
switch (question.type) {
|
|
58
|
+
case 'choice':
|
|
59
|
+
return serializeOrderedObject(question.options.map((option) => [option.key, JSON.stringify(option.criteria)]))
|
|
60
|
+
case 'score':
|
|
61
|
+
return JSON.stringify(question.levels)
|
|
62
|
+
case 'binary':
|
|
63
|
+
return JSON.stringify({ true: question.criteria.true, false: question.criteria.false })
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function wireQuestionType(question: DecisionQuestion): string {
|
|
68
|
+
return question.type === 'binary' ? 'noul' : question.type
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function serializeQuestion(question: DecisionQuestion): string {
|
|
72
|
+
return serializeOrderedObject([
|
|
73
|
+
['type', JSON.stringify(wireQuestionType(question))],
|
|
74
|
+
['instructions', JSON.stringify(question.instructions)],
|
|
75
|
+
['criteria', serializeCriteria(question)],
|
|
76
|
+
])
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function serializeRequestBody(request: DecisionRequest, modelId: string): string {
|
|
80
|
+
const entries: Array<[string, string]> = [
|
|
81
|
+
['model', JSON.stringify(modelId)],
|
|
82
|
+
['state', JSON.stringify(request.state) ?? 'null'],
|
|
83
|
+
['questions', serializeOrderedObject(
|
|
84
|
+
Object.entries(request.questions).map(([questionName, question]) => [questionName, serializeQuestion(question)]),
|
|
85
|
+
)],
|
|
86
|
+
]
|
|
87
|
+
if (request.sessionId !== undefined) {
|
|
88
|
+
entries.push(['session_id', JSON.stringify(request.sessionId)])
|
|
89
|
+
}
|
|
90
|
+
return serializeOrderedObject(entries)
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function mapHttpStatus(status: number): DecisionErrorCode {
|
|
94
|
+
switch (status) {
|
|
95
|
+
case 400:
|
|
96
|
+
case 422:
|
|
97
|
+
return 'invalid_request'
|
|
98
|
+
case 401:
|
|
99
|
+
case 403:
|
|
100
|
+
return 'auth'
|
|
101
|
+
case 402:
|
|
102
|
+
return 'insufficient_credits'
|
|
103
|
+
case 404:
|
|
104
|
+
return 'unavailable'
|
|
105
|
+
case 413:
|
|
106
|
+
return 'payload_too_large'
|
|
107
|
+
case 429:
|
|
108
|
+
return 'rate_limited'
|
|
109
|
+
case 524:
|
|
110
|
+
return 'timeout'
|
|
111
|
+
default:
|
|
112
|
+
return 'provider_error'
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function parseJson(text: string): unknown {
|
|
117
|
+
try {
|
|
118
|
+
return JSON.parse(text)
|
|
119
|
+
} catch {
|
|
120
|
+
return undefined
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function extractVendorErrorCode(body: unknown): string | undefined {
|
|
125
|
+
if (!body || typeof body !== 'object') return undefined
|
|
126
|
+
const error = (body as { error?: unknown }).error
|
|
127
|
+
if (!error || typeof error !== 'object') return undefined
|
|
128
|
+
const code = (error as { code?: unknown }).code
|
|
129
|
+
const asString = typeof code === 'number' ? String(code) : code
|
|
130
|
+
return typeof asString === 'string' && VENDOR_ERROR_CODE_PATTERN.test(asString) ? asString : undefined
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
async function readErrorBodySafely(response: Response): Promise<unknown> {
|
|
134
|
+
try {
|
|
135
|
+
return parseJson(await response.text())
|
|
136
|
+
} catch {
|
|
137
|
+
return undefined
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
async function throwHttpError(response: Response): Promise<never> {
|
|
142
|
+
const vendorCode = extractVendorErrorCode(await readErrorBodySafely(response))
|
|
143
|
+
const suffix = vendorCode ? ` (vendor code ${vendorCode})` : ''
|
|
144
|
+
throw new DecisionModelError(
|
|
145
|
+
mapHttpStatus(response.status),
|
|
146
|
+
`OpenRouter decision request failed with HTTP ${response.status}${suffix}`,
|
|
147
|
+
response.status,
|
|
148
|
+
)
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function unmappableAnswer(questionName: string): DecisionModelError {
|
|
152
|
+
return new DecisionModelError('invalid_response', `OpenRouter answer "${questionName}" has an unexpected shape`)
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
function mapChoiceAnswer(
|
|
156
|
+
questionName: string,
|
|
157
|
+
question: Extract<DecisionQuestion, { type: 'choice' }>,
|
|
158
|
+
wireAnswer: unknown,
|
|
159
|
+
): RawDecisionAnswer {
|
|
160
|
+
const parsed = wireChoiceAnswerSchema.safeParse(wireAnswer)
|
|
161
|
+
if (!parsed.success) throw unmappableAnswer(questionName)
|
|
162
|
+
const wireProbabilities = new Map(Object.entries(parsed.data.probabilities))
|
|
163
|
+
const requestKeys = question.options.map((option) => option.key)
|
|
164
|
+
const requestKeySet = new Set(requestKeys)
|
|
165
|
+
const orderedKeys = [
|
|
166
|
+
...requestKeys.filter((key) => wireProbabilities.has(key)),
|
|
167
|
+
...[...wireProbabilities.keys()].filter((key) => !requestKeySet.has(key)),
|
|
168
|
+
]
|
|
169
|
+
return {
|
|
170
|
+
type: 'choice',
|
|
171
|
+
value: parsed.data.choice,
|
|
172
|
+
probabilities: orderedKeys.map((key) => ({ key, probability: wireProbabilities.get(key) ?? 0 })),
|
|
173
|
+
confidence: parsed.data.confidence ?? null,
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
function mapScoreAnswer(questionName: string, wireAnswer: unknown): RawDecisionAnswer {
|
|
178
|
+
const parsed = wireScoreAnswerSchema.safeParse(wireAnswer)
|
|
179
|
+
if (!parsed.success) throw unmappableAnswer(questionName)
|
|
180
|
+
const wireProbabilities = parsed.data.probabilities
|
|
181
|
+
const probabilities = Array.isArray(wireProbabilities)
|
|
182
|
+
? wireProbabilities.map((probability, level) => ({ level, probability }))
|
|
183
|
+
: Object.entries(wireProbabilities).map(([levelKey, probability]) => {
|
|
184
|
+
if (!/^\d+$/.test(levelKey)) throw unmappableAnswer(questionName)
|
|
185
|
+
return { level: Number(levelKey), probability }
|
|
186
|
+
})
|
|
187
|
+
return { type: 'score', probabilities, confidence: parsed.data.confidence ?? null }
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
function mapBinaryAnswer(questionName: string, wireAnswer: unknown): RawDecisionAnswer {
|
|
191
|
+
const parsed = wireNoulAnswerSchema.safeParse(wireAnswer)
|
|
192
|
+
if (!parsed.success) throw unmappableAnswer(questionName)
|
|
193
|
+
return { type: 'binary', probability: parsed.data.noul }
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
function mapWireAnswers(request: DecisionRequest, wireAnswers: Record<string, unknown>): Record<string, RawDecisionAnswer> {
|
|
197
|
+
const raw: Record<string, RawDecisionAnswer> = {}
|
|
198
|
+
for (const [questionName, question] of Object.entries(request.questions)) {
|
|
199
|
+
if (!Object.prototype.hasOwnProperty.call(wireAnswers, questionName)) continue
|
|
200
|
+
const wireAnswer = wireAnswers[questionName]
|
|
201
|
+
switch (question.type) {
|
|
202
|
+
case 'choice':
|
|
203
|
+
raw[questionName] = mapChoiceAnswer(questionName, question, wireAnswer)
|
|
204
|
+
break
|
|
205
|
+
case 'score':
|
|
206
|
+
raw[questionName] = mapScoreAnswer(questionName, wireAnswer)
|
|
207
|
+
break
|
|
208
|
+
case 'binary':
|
|
209
|
+
raw[questionName] = mapBinaryAnswer(questionName, wireAnswer)
|
|
210
|
+
break
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
return raw
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
function readUsageNumber(usage: Record<string, unknown> | null | undefined, key: string): number | null {
|
|
217
|
+
const value = usage?.[key]
|
|
218
|
+
return typeof value === 'number' && Number.isFinite(value) ? value : null
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/**
|
|
222
|
+
* Decision model adapter for the OpenRouter Decisions API
|
|
223
|
+
* (`POST /api/alpha/decisions`). Maps the neutral port to the vendor wire
|
|
224
|
+
* shape and back; validation of the answers is delegated to
|
|
225
|
+
* `normalizeDecisionAnswers`.
|
|
226
|
+
*/
|
|
227
|
+
export function createOpenRouterDecisionAdapter(): DecisionModelAdapter {
|
|
228
|
+
return {
|
|
229
|
+
id: 'openrouter',
|
|
230
|
+
isConfigured: (env?: EnvLookup) => readEnv(env, API_KEY_ENV) !== undefined,
|
|
231
|
+
async decide(request: DecisionRequest, options): Promise<DecisionResult> {
|
|
232
|
+
const apiKey = readEnv(options.env, API_KEY_ENV)
|
|
233
|
+
if (!apiKey) {
|
|
234
|
+
throw new DecisionModelError('unavailable', 'OpenRouter decision adapter is not configured')
|
|
235
|
+
}
|
|
236
|
+
const startedAt = Date.now()
|
|
237
|
+
const response = await fetch(`${resolveBaseUrl(options.env)}/alpha/decisions`, {
|
|
238
|
+
method: 'POST',
|
|
239
|
+
headers: {
|
|
240
|
+
'Content-Type': 'application/json',
|
|
241
|
+
Authorization: `Bearer ${apiKey}`,
|
|
242
|
+
},
|
|
243
|
+
body: serializeRequestBody(request, options.modelId),
|
|
244
|
+
signal: options.signal,
|
|
245
|
+
})
|
|
246
|
+
if (!response.ok) {
|
|
247
|
+
await throwHttpError(response)
|
|
248
|
+
}
|
|
249
|
+
const parsed = wireResponseSchema.safeParse(parseJson(await response.text()))
|
|
250
|
+
if (!parsed.success) {
|
|
251
|
+
throw new DecisionModelError('provider_error', 'OpenRouter decision response body is not valid JSON of the expected shape')
|
|
252
|
+
}
|
|
253
|
+
const answers = normalizeDecisionAnswers(request, mapWireAnswers(request, parsed.data.answers))
|
|
254
|
+
return {
|
|
255
|
+
answers,
|
|
256
|
+
provider: 'openrouter',
|
|
257
|
+
model: parsed.data.model ?? options.modelId,
|
|
258
|
+
usage: {
|
|
259
|
+
inputTokens: readUsageNumber(parsed.data.usage, 'input_tokens'),
|
|
260
|
+
outputTokens: readUsageNumber(parsed.data.usage, 'output_tokens'),
|
|
261
|
+
costUsd: readUsageNumber(parsed.data.usage, 'cost'),
|
|
262
|
+
},
|
|
263
|
+
durationMs: Date.now() - startedAt,
|
|
264
|
+
}
|
|
265
|
+
},
|
|
266
|
+
}
|
|
267
|
+
}
|