@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,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
+ }