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