@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,108 @@
|
|
|
1
|
+
import type { DecisionModelAdapter } from '@open-mercato/shared/lib/ai/decision-model'
|
|
2
|
+
import type { EnvLookup } from '@open-mercato/shared/lib/ai/llm-provider'
|
|
3
|
+
import { parseBooleanToken } from '@open-mercato/shared/lib/boolean'
|
|
4
|
+
import { createFixtureDecisionAdapter } from './decision-adapters/fixture'
|
|
5
|
+
import { createOpenRouterDecisionAdapter } from './decision-adapters/openrouter'
|
|
6
|
+
|
|
7
|
+
const PROVIDER_ENV = 'OM_DECISION_PROVIDER'
|
|
8
|
+
const MODEL_ENV = 'OM_DECISION_MODEL'
|
|
9
|
+
const FIXTURE_ENABLED_ENV = 'OM_DECISION_FIXTURE_ENABLED'
|
|
10
|
+
const FIXTURE_ADAPTER_ID = 'fixture'
|
|
11
|
+
|
|
12
|
+
/** @experimental Why no decision model adapter could be resolved. */
|
|
13
|
+
export type DecisionModelUnavailableReason =
|
|
14
|
+
| 'not_configured'
|
|
15
|
+
| 'unknown_provider'
|
|
16
|
+
| 'credentials_missing'
|
|
17
|
+
| 'model_missing'
|
|
18
|
+
| 'fixture_disabled'
|
|
19
|
+
|
|
20
|
+
/** @experimental Outcome of resolving the active decision model adapter from env. */
|
|
21
|
+
export type DecisionModelResolution =
|
|
22
|
+
| { ok: true; adapter: DecisionModelAdapter; modelId: string }
|
|
23
|
+
| { ok: false; reason: DecisionModelUnavailableReason }
|
|
24
|
+
|
|
25
|
+
/** @experimental Pure, instance-scoped registry of decision model adapters. */
|
|
26
|
+
export interface DecisionModelRegistry {
|
|
27
|
+
/** Registers an adapter. Idempotent by id: re-registering an id replaces the previous entry in place. */
|
|
28
|
+
register(adapter: DecisionModelAdapter): void
|
|
29
|
+
get(id: string): DecisionModelAdapter | undefined
|
|
30
|
+
/** Registered adapters in registration order. */
|
|
31
|
+
list(): DecisionModelAdapter[]
|
|
32
|
+
/**
|
|
33
|
+
* Resolves the adapter and model from `OM_DECISION_PROVIDER`,
|
|
34
|
+
* `OM_DECISION_<MODULE>_MODEL` and `OM_DECISION_MODEL`.
|
|
35
|
+
*/
|
|
36
|
+
resolve(input?: { moduleId?: string; env?: EnvLookup }): DecisionModelResolution
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** Env key of the per-module model override, e.g. `workflows` → `OM_DECISION_WORKFLOWS_MODEL`. */
|
|
40
|
+
export function decisionModuleModelEnvKey(moduleId: string): string {
|
|
41
|
+
return `OM_DECISION_${moduleId.toUpperCase().replace(/[^A-Z0-9]/g, '_')}_MODEL`
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** True when the fixture adapter is opted in and the process is not running in production. */
|
|
45
|
+
export function isDecisionFixtureEnabled(env: EnvLookup): boolean {
|
|
46
|
+
return parseBooleanToken(env[FIXTURE_ENABLED_ENV]) === true && env.NODE_ENV !== 'production'
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function readTrimmed(env: EnvLookup, key: string): string | undefined {
|
|
50
|
+
const value = env[key]
|
|
51
|
+
if (typeof value !== 'string') return undefined
|
|
52
|
+
const trimmed = value.trim()
|
|
53
|
+
return trimmed ? trimmed : undefined
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function normalizeId(id: string): string {
|
|
57
|
+
return id.trim().toLowerCase()
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Creates a decision model registry with its built-in adapters registered:
|
|
62
|
+
* `openrouter` always, `fixture` only when `isDecisionFixtureEnabled` holds
|
|
63
|
+
* for the creation env.
|
|
64
|
+
*
|
|
65
|
+
* @experimental
|
|
66
|
+
*/
|
|
67
|
+
export function createDecisionModelRegistry(input?: { env?: EnvLookup }): DecisionModelRegistry {
|
|
68
|
+
const creationEnv = input?.env
|
|
69
|
+
const adapters = new Map<string, DecisionModelAdapter>()
|
|
70
|
+
|
|
71
|
+
const registry: DecisionModelRegistry = {
|
|
72
|
+
register(adapter) {
|
|
73
|
+
adapters.set(normalizeId(adapter.id), adapter)
|
|
74
|
+
},
|
|
75
|
+
get(id) {
|
|
76
|
+
return adapters.get(normalizeId(id))
|
|
77
|
+
},
|
|
78
|
+
list() {
|
|
79
|
+
return Array.from(adapters.values())
|
|
80
|
+
},
|
|
81
|
+
resolve(options) {
|
|
82
|
+
const lookup = options?.env ?? creationEnv ?? process.env
|
|
83
|
+
const providerId = readTrimmed(lookup, PROVIDER_ENV)
|
|
84
|
+
if (!providerId) return { ok: false, reason: 'not_configured' }
|
|
85
|
+
const normalizedProvider = normalizeId(providerId)
|
|
86
|
+
const adapter = adapters.get(normalizedProvider)
|
|
87
|
+
if (!adapter) {
|
|
88
|
+
return {
|
|
89
|
+
ok: false,
|
|
90
|
+
reason: normalizedProvider === FIXTURE_ADAPTER_ID ? 'fixture_disabled' : 'unknown_provider',
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
if (!adapter.isConfigured(lookup)) return { ok: false, reason: 'credentials_missing' }
|
|
94
|
+
const moduleModel = options?.moduleId
|
|
95
|
+
? readTrimmed(lookup, decisionModuleModelEnvKey(options.moduleId))
|
|
96
|
+
: undefined
|
|
97
|
+
const modelId = moduleModel ?? readTrimmed(lookup, MODEL_ENV)
|
|
98
|
+
if (!modelId) return { ok: false, reason: 'model_missing' }
|
|
99
|
+
return { ok: true, adapter, modelId }
|
|
100
|
+
},
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
registry.register(createOpenRouterDecisionAdapter())
|
|
104
|
+
if (isDecisionFixtureEnabled(creationEnv ?? process.env)) {
|
|
105
|
+
registry.register(createFixtureDecisionAdapter())
|
|
106
|
+
}
|
|
107
|
+
return registry
|
|
108
|
+
}
|
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Zod schemas and response normalization for the decision model port.
|
|
3
|
+
*
|
|
4
|
+
* The port types live in `@open-mercato/shared/lib/ai/decision-model` (types
|
|
5
|
+
* only); these schemas are their runtime counterpart and stay internal to
|
|
6
|
+
* `ai-assistant`. A type-level test keeps the two from drifting.
|
|
7
|
+
*
|
|
8
|
+
* @see .ai/specs/2026-10-06-ai-decision-model-port.md
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { z } from 'zod'
|
|
12
|
+
import { createLogger } from '@open-mercato/shared/lib/logger'
|
|
13
|
+
import type {
|
|
14
|
+
DecisionAnswer,
|
|
15
|
+
DecisionErrorCode,
|
|
16
|
+
DecisionQuestion,
|
|
17
|
+
DecisionRequest,
|
|
18
|
+
} from '@open-mercato/shared/lib/ai/decision-model'
|
|
19
|
+
|
|
20
|
+
const decisionKeySchema = z.string().regex(/^[A-Za-z0-9_-]{1,64}$/).refine((key) => key !== '__proto__', {
|
|
21
|
+
message: 'Reserved key',
|
|
22
|
+
})
|
|
23
|
+
|
|
24
|
+
export const decisionChoiceQuestionSchema = z.object({
|
|
25
|
+
type: z.literal('choice'),
|
|
26
|
+
instructions: z.string().min(1).max(4000),
|
|
27
|
+
options: z.array(z.object({ key: decisionKeySchema, criteria: z.string().min(1).max(500) }))
|
|
28
|
+
.min(2).max(64)
|
|
29
|
+
.refine((options) => new Set(options.map((option) => option.key)).size === options.length, {
|
|
30
|
+
message: 'Option keys must be unique',
|
|
31
|
+
}),
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
export const decisionScoreQuestionSchema = z.object({
|
|
35
|
+
type: z.literal('score'),
|
|
36
|
+
instructions: z.string().min(1).max(4000),
|
|
37
|
+
levels: z.array(z.string().min(1).max(500)).min(2).max(16),
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
export const decisionBinaryQuestionSchema = z.object({
|
|
41
|
+
type: z.literal('binary'),
|
|
42
|
+
instructions: z.string().min(1).max(4000),
|
|
43
|
+
criteria: z.object({ true: z.string().min(1).max(500), false: z.string().min(1).max(500) }),
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
export const decisionQuestionSchema = z.discriminatedUnion('type', [
|
|
47
|
+
decisionChoiceQuestionSchema,
|
|
48
|
+
decisionScoreQuestionSchema,
|
|
49
|
+
decisionBinaryQuestionSchema,
|
|
50
|
+
])
|
|
51
|
+
|
|
52
|
+
export const decisionRequestSchema = z.object({
|
|
53
|
+
state: z.unknown(),
|
|
54
|
+
questions: z.unknown()
|
|
55
|
+
.refine((raw) => !(typeof raw === 'object' && raw !== null && Object.prototype.hasOwnProperty.call(raw, '__proto__')), {
|
|
56
|
+
message: 'Reserved question name',
|
|
57
|
+
})
|
|
58
|
+
.pipe(z.record(decisionKeySchema, decisionQuestionSchema))
|
|
59
|
+
.refine((questions) => Object.keys(questions).length >= 1 && Object.keys(questions).length <= 8, {
|
|
60
|
+
message: 'Between 1 and 8 questions are required',
|
|
61
|
+
}),
|
|
62
|
+
sessionId: z.string().max(256).optional(),
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Error thrown by adapters and internal helpers. The service catches it and
|
|
67
|
+
* turns it into the closed `{ ok: false, error }` result; it never reaches
|
|
68
|
+
* consumers.
|
|
69
|
+
*/
|
|
70
|
+
export class DecisionModelError extends Error {
|
|
71
|
+
readonly code: DecisionErrorCode
|
|
72
|
+
readonly httpStatus?: number
|
|
73
|
+
|
|
74
|
+
constructor(code: DecisionErrorCode, message: string, httpStatus?: number) {
|
|
75
|
+
super(`[internal] ${message}`)
|
|
76
|
+
this.name = 'DecisionModelError'
|
|
77
|
+
this.code = code
|
|
78
|
+
this.httpStatus = httpStatus
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export const PROBABILITY_SUM_TOLERANCE = 0.02
|
|
83
|
+
|
|
84
|
+
const EXACT_SUM_EPSILON = 1e-9
|
|
85
|
+
|
|
86
|
+
const logger = createLogger('ai_assistant').child({ component: 'decision-model' })
|
|
87
|
+
|
|
88
|
+
function logNormalized(questionName: string, sum: number): void {
|
|
89
|
+
try {
|
|
90
|
+
logger.debug('Normalized decision probabilities', { questionName, sum })
|
|
91
|
+
} catch {
|
|
92
|
+
return
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const probabilitySchema = z.number().finite().min(0).max(1)
|
|
97
|
+
|
|
98
|
+
const rawChoiceAnswerSchema = z.object({
|
|
99
|
+
type: z.literal('choice'),
|
|
100
|
+
value: z.string(),
|
|
101
|
+
probabilities: z.array(z.object({ key: z.string(), probability: probabilitySchema })),
|
|
102
|
+
confidence: probabilitySchema.nullable(),
|
|
103
|
+
})
|
|
104
|
+
|
|
105
|
+
const rawScoreAnswerSchema = z.object({
|
|
106
|
+
type: z.literal('score'),
|
|
107
|
+
probabilities: z.array(z.object({ level: z.number().int(), probability: probabilitySchema })),
|
|
108
|
+
confidence: probabilitySchema.nullable(),
|
|
109
|
+
})
|
|
110
|
+
|
|
111
|
+
const rawBinaryAnswerSchema = z.object({
|
|
112
|
+
type: z.literal('binary'),
|
|
113
|
+
probability: probabilitySchema,
|
|
114
|
+
})
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* What an adapter hands back per question before the port validates it. For
|
|
118
|
+
* `score`, the port derives `value` and `level` itself, so adapters only
|
|
119
|
+
* supply the distribution.
|
|
120
|
+
*/
|
|
121
|
+
export const rawDecisionAnswerSchema = z.discriminatedUnion('type', [
|
|
122
|
+
rawChoiceAnswerSchema,
|
|
123
|
+
rawScoreAnswerSchema,
|
|
124
|
+
rawBinaryAnswerSchema,
|
|
125
|
+
])
|
|
126
|
+
|
|
127
|
+
export type RawDecisionAnswer = z.infer<typeof rawDecisionAnswerSchema>
|
|
128
|
+
|
|
129
|
+
function invalidResponse(questionName: string, reason: string): DecisionModelError {
|
|
130
|
+
return new DecisionModelError('invalid_response', `Decision answer "${questionName}" ${reason}`)
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function normalizeDistribution(questionName: string, probabilities: number[]): number[] {
|
|
134
|
+
const sum = probabilities.reduce((total, probability) => total + probability, 0)
|
|
135
|
+
if (Math.abs(sum - 1) > PROBABILITY_SUM_TOLERANCE) {
|
|
136
|
+
throw invalidResponse(questionName, 'has probabilities that do not sum to 1')
|
|
137
|
+
}
|
|
138
|
+
if (Math.abs(sum - 1) > EXACT_SUM_EPSILON) logNormalized(questionName, sum)
|
|
139
|
+
return probabilities.map((probability) => probability / sum)
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function argmax(values: number[]): number {
|
|
143
|
+
return values.reduce((bestIndex, value, index) => (value > values[bestIndex] ? index : bestIndex), 0)
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function normalizeChoice(
|
|
147
|
+
questionName: string,
|
|
148
|
+
question: Extract<DecisionQuestion, { type: 'choice' }>,
|
|
149
|
+
answer: z.infer<typeof rawChoiceAnswerSchema>,
|
|
150
|
+
): DecisionAnswer {
|
|
151
|
+
const byKey = new Map(answer.probabilities.map((entry) => [entry.key, entry.probability]))
|
|
152
|
+
const requestKeys = question.options.map((option) => option.key)
|
|
153
|
+
if (byKey.size !== answer.probabilities.length || byKey.size !== requestKeys.length || requestKeys.some((key) => !byKey.has(key))) {
|
|
154
|
+
throw invalidResponse(questionName, 'does not cover exactly the requested options')
|
|
155
|
+
}
|
|
156
|
+
if (!byKey.has(answer.value)) {
|
|
157
|
+
throw invalidResponse(questionName, 'selected an option that was not requested')
|
|
158
|
+
}
|
|
159
|
+
const normalized = normalizeDistribution(questionName, requestKeys.map((key) => byKey.get(key) ?? 0))
|
|
160
|
+
return {
|
|
161
|
+
type: 'choice',
|
|
162
|
+
value: answer.value,
|
|
163
|
+
probabilities: requestKeys.map((key, index) => ({ key, probability: normalized[index] })),
|
|
164
|
+
confidence: answer.confidence,
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
function normalizeScore(
|
|
169
|
+
questionName: string,
|
|
170
|
+
question: Extract<DecisionQuestion, { type: 'score' }>,
|
|
171
|
+
answer: z.infer<typeof rawScoreAnswerSchema>,
|
|
172
|
+
): DecisionAnswer {
|
|
173
|
+
const byLevel = new Map(answer.probabilities.map((entry) => [entry.level, entry.probability]))
|
|
174
|
+
const levelCount = question.levels.length
|
|
175
|
+
const levelIndexes = question.levels.map((_, index) => index)
|
|
176
|
+
if (byLevel.size !== answer.probabilities.length || byLevel.size !== levelCount || levelIndexes.some((level) => !byLevel.has(level))) {
|
|
177
|
+
throw invalidResponse(questionName, 'does not cover exactly the requested levels')
|
|
178
|
+
}
|
|
179
|
+
const normalized = normalizeDistribution(questionName, levelIndexes.map((level) => byLevel.get(level) ?? 0))
|
|
180
|
+
const value = normalized.reduce((total, probability, level) => total + probability * level, 0)
|
|
181
|
+
return {
|
|
182
|
+
type: 'score',
|
|
183
|
+
value,
|
|
184
|
+
level: argmax(normalized),
|
|
185
|
+
probabilities: levelIndexes.map((level) => ({ level, probability: normalized[level] })),
|
|
186
|
+
confidence: answer.confidence,
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* Validates adapter output against the request and returns port answers:
|
|
192
|
+
* every question answered with a matching type, probabilities in [0, 1],
|
|
193
|
+
* key/level sets equal to the request's, the chosen value among them, and
|
|
194
|
+
* `choice`/`score` distributions summing to 1 ± 0.02 (then normalized to
|
|
195
|
+
* exactly 1). `probabilities` come back in request order. Any violation
|
|
196
|
+
* throws `DecisionModelError('invalid_response')`.
|
|
197
|
+
*/
|
|
198
|
+
export function normalizeDecisionAnswers(
|
|
199
|
+
request: DecisionRequest,
|
|
200
|
+
rawAnswers: Record<string, unknown>,
|
|
201
|
+
): Record<string, DecisionAnswer> {
|
|
202
|
+
const answers: Record<string, DecisionAnswer> = {}
|
|
203
|
+
for (const [questionName, question] of Object.entries(request.questions)) {
|
|
204
|
+
if (!Object.prototype.hasOwnProperty.call(rawAnswers, questionName)) {
|
|
205
|
+
throw invalidResponse(questionName, 'is missing')
|
|
206
|
+
}
|
|
207
|
+
const parsed = rawDecisionAnswerSchema.safeParse(rawAnswers[questionName])
|
|
208
|
+
if (!parsed.success) {
|
|
209
|
+
throw invalidResponse(questionName, 'has an invalid shape')
|
|
210
|
+
}
|
|
211
|
+
const answer = parsed.data
|
|
212
|
+
if (answer.type !== question.type) {
|
|
213
|
+
throw invalidResponse(questionName, 'has a type that does not match the question')
|
|
214
|
+
}
|
|
215
|
+
if (answer.type === 'choice' && question.type === 'choice') {
|
|
216
|
+
answers[questionName] = normalizeChoice(questionName, question, answer)
|
|
217
|
+
} else if (answer.type === 'score' && question.type === 'score') {
|
|
218
|
+
answers[questionName] = normalizeScore(questionName, question, answer)
|
|
219
|
+
} else if (answer.type === 'binary') {
|
|
220
|
+
answers[questionName] = { type: 'binary', probability: answer.probability }
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
return answers
|
|
224
|
+
}
|
|
@@ -0,0 +1,297 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
DecisionErrorCode,
|
|
3
|
+
DecisionRequest,
|
|
4
|
+
DecisionResult,
|
|
5
|
+
} from '@open-mercato/shared/lib/ai/decision-model'
|
|
6
|
+
import type { EnvLookup } from '@open-mercato/shared/lib/ai/llm-provider'
|
|
7
|
+
import { countTokens } from '@open-mercato/shared/lib/ai/token-count'
|
|
8
|
+
import { createLogger, type Logger } from '@open-mercato/shared/lib/logger'
|
|
9
|
+
import { parseNumberWithDefault } from '@open-mercato/shared/lib/number'
|
|
10
|
+
import { getTelemetryRuntime } from '@open-mercato/shared/lib/telemetry/runtime'
|
|
11
|
+
import {
|
|
12
|
+
createDecisionModelRegistry,
|
|
13
|
+
type DecisionModelRegistry,
|
|
14
|
+
type DecisionModelUnavailableReason,
|
|
15
|
+
} from './decision-model-registry'
|
|
16
|
+
import { DecisionModelError, decisionRequestSchema, normalizeDecisionAnswers } from './decision-model-schemas'
|
|
17
|
+
|
|
18
|
+
export const DEFAULT_DECISION_TIMEOUT_MS = 5000
|
|
19
|
+
export const MAX_DECISION_TIMEOUT_MS = 30000
|
|
20
|
+
export const DEFAULT_DECISION_MAX_INPUT_TOKENS = 24000
|
|
21
|
+
|
|
22
|
+
const MAX_INPUT_TOKENS_ENV = 'OM_DECISION_MAX_INPUT_TOKENS'
|
|
23
|
+
|
|
24
|
+
/** @experimental Availability of the decision model for an optional module. */
|
|
25
|
+
export interface DecisionModelAvailability {
|
|
26
|
+
available: boolean
|
|
27
|
+
provider?: string
|
|
28
|
+
model?: string
|
|
29
|
+
reason?: DecisionModelUnavailableReason
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** @experimental Closed error shape returned by `decide`. */
|
|
33
|
+
export interface DecisionModelDecideError {
|
|
34
|
+
code: DecisionErrorCode
|
|
35
|
+
message: string
|
|
36
|
+
httpStatus?: number
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** @experimental Discriminated result of `decide`; it never throws. */
|
|
40
|
+
export type DecisionModelDecideResult =
|
|
41
|
+
| { ok: true; result: DecisionResult }
|
|
42
|
+
| { ok: false; error: DecisionModelDecideError }
|
|
43
|
+
|
|
44
|
+
/** @experimental Input of `decide`. */
|
|
45
|
+
export interface DecisionModelDecideInput {
|
|
46
|
+
request: DecisionRequest
|
|
47
|
+
moduleId?: string
|
|
48
|
+
timeoutMs?: number
|
|
49
|
+
signal?: AbortSignal
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** @experimental DI service `decisionModelService`. */
|
|
53
|
+
export interface DecisionModelService {
|
|
54
|
+
getAvailability(input?: { moduleId?: string }): DecisionModelAvailability
|
|
55
|
+
decide(input: DecisionModelDecideInput): Promise<DecisionModelDecideResult>
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** Minimal logger shape the service writes through. */
|
|
59
|
+
export type DecisionModelServiceLogger = Pick<Logger, 'info' | 'warn'>
|
|
60
|
+
|
|
61
|
+
/** Error reporter the service calls on `provider_error` / `invalid_response`. */
|
|
62
|
+
export type DecisionModelReportError = (
|
|
63
|
+
error: unknown,
|
|
64
|
+
context: { module: string; code: string },
|
|
65
|
+
) => void
|
|
66
|
+
|
|
67
|
+
/** Optional dependencies of `createDecisionModelService`, mainly for tests and overrides. */
|
|
68
|
+
export interface DecisionModelServiceDeps {
|
|
69
|
+
registry?: DecisionModelRegistry
|
|
70
|
+
env?: EnvLookup
|
|
71
|
+
logger?: DecisionModelServiceLogger
|
|
72
|
+
reportError?: DecisionModelReportError
|
|
73
|
+
now?: () => number
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const ERROR_MESSAGES: Record<DecisionErrorCode, string> = {
|
|
77
|
+
unavailable: 'Decision model is unavailable',
|
|
78
|
+
invalid_request: 'Decision request is invalid',
|
|
79
|
+
auth: 'Decision provider rejected the credentials',
|
|
80
|
+
insufficient_credits: 'Decision provider credits are exhausted',
|
|
81
|
+
rate_limited: 'Decision provider rate limit reached',
|
|
82
|
+
payload_too_large: 'Decision request is too large',
|
|
83
|
+
timeout: 'Decision request timed out',
|
|
84
|
+
aborted: 'Decision request was aborted',
|
|
85
|
+
provider_error: 'Decision provider failed',
|
|
86
|
+
invalid_response: 'Decision provider returned an invalid response',
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const REPORTED_ERROR_CODES: Partial<Record<DecisionErrorCode, string>> = {
|
|
90
|
+
provider_error: 'ai_assistant.decision_provider_error',
|
|
91
|
+
invalid_response: 'ai_assistant.decision_invalid_response',
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function messageFor(code: DecisionErrorCode): string {
|
|
95
|
+
return ERROR_MESSAGES[code] ?? 'Decision request failed'
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** Clamps a caller timeout to the port bounds: default 5 s, maximum 30 s. */
|
|
99
|
+
export function resolveDecisionTimeoutMs(timeoutMs: number | undefined): number {
|
|
100
|
+
if (typeof timeoutMs !== 'number' || !Number.isFinite(timeoutMs) || timeoutMs <= 0) {
|
|
101
|
+
return DEFAULT_DECISION_TIMEOUT_MS
|
|
102
|
+
}
|
|
103
|
+
return Math.min(timeoutMs, MAX_DECISION_TIMEOUT_MS)
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function resolveMaxInputTokens(env: EnvLookup): number {
|
|
107
|
+
const raw = env[MAX_INPUT_TOKENS_ENV]
|
|
108
|
+
return parseNumberWithDefault(typeof raw === 'string' ? raw : undefined, DEFAULT_DECISION_MAX_INPUT_TOKENS, {
|
|
109
|
+
min: 1,
|
|
110
|
+
integer: true,
|
|
111
|
+
})
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function defaultReportError(error: unknown, context: { module: string; code: string }): void {
|
|
115
|
+
getTelemetryRuntime()?.reportError(error, context)
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function abortPromise(signal: AbortSignal): { promise: Promise<never>; cleanup: () => void } {
|
|
119
|
+
let listener: (() => void) | undefined
|
|
120
|
+
const promise = new Promise<never>((_resolve, reject) => {
|
|
121
|
+
if (signal.aborted) {
|
|
122
|
+
reject(signal.reason)
|
|
123
|
+
return
|
|
124
|
+
}
|
|
125
|
+
listener = () => reject(signal.reason)
|
|
126
|
+
signal.addEventListener('abort', listener, { once: true })
|
|
127
|
+
})
|
|
128
|
+
return {
|
|
129
|
+
promise,
|
|
130
|
+
cleanup: () => {
|
|
131
|
+
if (listener) signal.removeEventListener('abort', listener)
|
|
132
|
+
},
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
class DecisionFailure extends Error {
|
|
137
|
+
readonly failure: DecisionModelDecideError
|
|
138
|
+
readonly origin: unknown
|
|
139
|
+
|
|
140
|
+
constructor(failure: DecisionModelDecideError, origin?: unknown) {
|
|
141
|
+
super(`[internal] ${failure.message}`)
|
|
142
|
+
this.name = 'DecisionFailure'
|
|
143
|
+
this.failure = failure
|
|
144
|
+
this.origin = origin
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function fail(code: DecisionErrorCode, message?: string, httpStatus?: number, origin?: unknown): DecisionFailure {
|
|
149
|
+
const failure: DecisionModelDecideError = { code, message: message ?? messageFor(code) }
|
|
150
|
+
if (httpStatus !== undefined) failure.httpStatus = httpStatus
|
|
151
|
+
return new DecisionFailure(failure, origin)
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* Creates the decision model service. `decide` never throws or rejects: every
|
|
156
|
+
* failure becomes `{ ok: false, error }`. The request state, instructions,
|
|
157
|
+
* criteria and session id are never logged.
|
|
158
|
+
*
|
|
159
|
+
* @experimental
|
|
160
|
+
*/
|
|
161
|
+
export function createDecisionModelService(deps: DecisionModelServiceDeps = {}): DecisionModelService {
|
|
162
|
+
let registry: DecisionModelRegistry | undefined = deps.registry
|
|
163
|
+
let logger: DecisionModelServiceLogger | undefined = deps.logger
|
|
164
|
+
const env: EnvLookup = deps.env ?? process.env
|
|
165
|
+
const reportError: DecisionModelReportError = deps.reportError ?? defaultReportError
|
|
166
|
+
const now = deps.now ?? (() => Date.now())
|
|
167
|
+
|
|
168
|
+
const getRegistry = (): DecisionModelRegistry => {
|
|
169
|
+
if (!registry) registry = createDecisionModelRegistry({ env })
|
|
170
|
+
return registry
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
const getLogger = (): DecisionModelServiceLogger => {
|
|
174
|
+
if (!logger) logger = createLogger('ai_assistant').child({ component: 'decision-model' })
|
|
175
|
+
return logger
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
const safeLog = (level: 'info' | 'warn', fields: Record<string, unknown>): void => {
|
|
179
|
+
try {
|
|
180
|
+
getLogger()[level]('decision model call', fields)
|
|
181
|
+
} catch {
|
|
182
|
+
return
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
const safeReport = (code: DecisionErrorCode, error: unknown): void => {
|
|
187
|
+
const reportCode = REPORTED_ERROR_CODES[code]
|
|
188
|
+
if (!reportCode) return
|
|
189
|
+
try {
|
|
190
|
+
reportError(error, { module: 'ai_assistant', code: reportCode })
|
|
191
|
+
} catch {
|
|
192
|
+
return
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
const getAvailability: DecisionModelService['getAvailability'] = (input) => {
|
|
197
|
+
try {
|
|
198
|
+
const resolution = getRegistry().resolve({ moduleId: input?.moduleId, env })
|
|
199
|
+
if (!resolution.ok) return { available: false, reason: resolution.reason }
|
|
200
|
+
return { available: true, provider: resolution.adapter.id, model: resolution.modelId }
|
|
201
|
+
} catch {
|
|
202
|
+
return { available: false, reason: 'not_configured' }
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
const decide: DecisionModelService['decide'] = async (input) => {
|
|
207
|
+
const startedAt = now()
|
|
208
|
+
let provider: string | undefined
|
|
209
|
+
let model: string | undefined
|
|
210
|
+
let questionCount = 0
|
|
211
|
+
try {
|
|
212
|
+
const parsed = decisionRequestSchema.safeParse(input?.request)
|
|
213
|
+
if (!parsed.success) throw fail('invalid_request')
|
|
214
|
+
const request: DecisionRequest = parsed.data
|
|
215
|
+
questionCount = Object.keys(request.questions).length
|
|
216
|
+
|
|
217
|
+
let serialized: string | undefined
|
|
218
|
+
try {
|
|
219
|
+
serialized = JSON.stringify({ state: request.state, questions: request.questions })
|
|
220
|
+
} catch {
|
|
221
|
+
throw fail('invalid_request', 'Decision request state cannot be serialized')
|
|
222
|
+
}
|
|
223
|
+
if (countTokens(serialized ?? '') > resolveMaxInputTokens(env)) throw fail('payload_too_large')
|
|
224
|
+
|
|
225
|
+
const resolution = getRegistry().resolve({ moduleId: input.moduleId, env })
|
|
226
|
+
if (!resolution.ok) {
|
|
227
|
+
throw fail('unavailable', `Decision model unavailable: ${resolution.reason}`)
|
|
228
|
+
}
|
|
229
|
+
provider = resolution.adapter.id
|
|
230
|
+
model = resolution.modelId
|
|
231
|
+
|
|
232
|
+
const callerSignal = input.signal
|
|
233
|
+
if (callerSignal?.aborted) throw fail('aborted')
|
|
234
|
+
|
|
235
|
+
const timeoutMs = resolveDecisionTimeoutMs(input.timeoutMs)
|
|
236
|
+
const timeoutController = new AbortController()
|
|
237
|
+
const timeoutReason = new DecisionModelError('timeout', `Decision request exceeded ${timeoutMs} ms`)
|
|
238
|
+
const timer = setTimeout(() => timeoutController.abort(timeoutReason), timeoutMs)
|
|
239
|
+
const combined = AbortSignal.any([timeoutController.signal, ...(callerSignal ? [callerSignal] : [])])
|
|
240
|
+
const abort = abortPromise(combined)
|
|
241
|
+
|
|
242
|
+
let result: DecisionResult
|
|
243
|
+
try {
|
|
244
|
+
result = await Promise.race([
|
|
245
|
+
resolution.adapter.decide(request, { modelId: resolution.modelId, env, signal: combined }),
|
|
246
|
+
abort.promise,
|
|
247
|
+
])
|
|
248
|
+
} catch (error) {
|
|
249
|
+
if (combined.aborted) {
|
|
250
|
+
const abortCode: DecisionErrorCode = combined.reason === timeoutReason ? 'timeout' : 'aborted'
|
|
251
|
+
throw fail(abortCode, undefined, undefined, error)
|
|
252
|
+
}
|
|
253
|
+
if (error instanceof DecisionModelError) {
|
|
254
|
+
throw fail(error.code, undefined, error.httpStatus, error)
|
|
255
|
+
}
|
|
256
|
+
throw fail('provider_error', undefined, undefined, error)
|
|
257
|
+
} finally {
|
|
258
|
+
clearTimeout(timer)
|
|
259
|
+
abort.cleanup()
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
let answers: DecisionResult['answers']
|
|
263
|
+
try {
|
|
264
|
+
answers = normalizeDecisionAnswers(request, result.answers)
|
|
265
|
+
} catch (error) {
|
|
266
|
+
throw fail('invalid_response', undefined, undefined, error)
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
const durationMs = now() - startedAt
|
|
270
|
+
const finalResult: DecisionResult = { ...result, answers, durationMs }
|
|
271
|
+
safeLog('info', {
|
|
272
|
+
provider: finalResult.provider,
|
|
273
|
+
model: finalResult.model,
|
|
274
|
+
durationMs,
|
|
275
|
+
usage: finalResult.usage,
|
|
276
|
+
questionCount,
|
|
277
|
+
outcomeCodes: ['ok'],
|
|
278
|
+
})
|
|
279
|
+
return { ok: true, result: finalResult }
|
|
280
|
+
} catch (error) {
|
|
281
|
+
const failure = error instanceof DecisionFailure ? error.failure : fail('provider_error').failure
|
|
282
|
+
const cause = error instanceof DecisionFailure ? error.origin ?? error : error
|
|
283
|
+
safeLog('warn', {
|
|
284
|
+
provider: provider ?? null,
|
|
285
|
+
model: model ?? null,
|
|
286
|
+
durationMs: now() - startedAt,
|
|
287
|
+
usage: null,
|
|
288
|
+
questionCount,
|
|
289
|
+
outcomeCodes: [failure.code],
|
|
290
|
+
})
|
|
291
|
+
safeReport(failure.code, cause)
|
|
292
|
+
return { ok: false, error: failure }
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
return { getAvailability, decide }
|
|
297
|
+
}
|