@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,7 @@
|
|
|
1
|
+
{
|
|
2
|
+
"version": 3,
|
|
3
|
+
"sources": ["../../../../src/modules/ai_assistant/lib/decision-model-schemas.ts"],
|
|
4
|
+
"sourcesContent": ["/**\n * Zod schemas and response normalization for the decision model port.\n *\n * The port types live in `@open-mercato/shared/lib/ai/decision-model` (types\n * only); these schemas are their runtime counterpart and stay internal to\n * `ai-assistant`. A type-level test keeps the two from drifting.\n *\n * @see .ai/specs/2026-10-06-ai-decision-model-port.md\n */\n\nimport { z } from 'zod'\nimport { createLogger } from '@open-mercato/shared/lib/logger'\nimport type {\n DecisionAnswer,\n DecisionErrorCode,\n DecisionQuestion,\n DecisionRequest,\n} from '@open-mercato/shared/lib/ai/decision-model'\n\nconst decisionKeySchema = z.string().regex(/^[A-Za-z0-9_-]{1,64}$/).refine((key) => key !== '__proto__', {\n message: 'Reserved key',\n})\n\nexport const decisionChoiceQuestionSchema = z.object({\n type: z.literal('choice'),\n instructions: z.string().min(1).max(4000),\n options: z.array(z.object({ key: decisionKeySchema, criteria: z.string().min(1).max(500) }))\n .min(2).max(64)\n .refine((options) => new Set(options.map((option) => option.key)).size === options.length, {\n message: 'Option keys must be unique',\n }),\n})\n\nexport const decisionScoreQuestionSchema = z.object({\n type: z.literal('score'),\n instructions: z.string().min(1).max(4000),\n levels: z.array(z.string().min(1).max(500)).min(2).max(16),\n})\n\nexport const decisionBinaryQuestionSchema = z.object({\n type: z.literal('binary'),\n instructions: z.string().min(1).max(4000),\n criteria: z.object({ true: z.string().min(1).max(500), false: z.string().min(1).max(500) }),\n})\n\nexport const decisionQuestionSchema = z.discriminatedUnion('type', [\n decisionChoiceQuestionSchema,\n decisionScoreQuestionSchema,\n decisionBinaryQuestionSchema,\n])\n\nexport const decisionRequestSchema = z.object({\n state: z.unknown(),\n questions: z.unknown()\n .refine((raw) => !(typeof raw === 'object' && raw !== null && Object.prototype.hasOwnProperty.call(raw, '__proto__')), {\n message: 'Reserved question name',\n })\n .pipe(z.record(decisionKeySchema, decisionQuestionSchema))\n .refine((questions) => Object.keys(questions).length >= 1 && Object.keys(questions).length <= 8, {\n message: 'Between 1 and 8 questions are required',\n }),\n sessionId: z.string().max(256).optional(),\n})\n\n/**\n * Error thrown by adapters and internal helpers. The service catches it and\n * turns it into the closed `{ ok: false, error }` result; it never reaches\n * consumers.\n */\nexport class DecisionModelError extends Error {\n readonly code: DecisionErrorCode\n readonly httpStatus?: number\n\n constructor(code: DecisionErrorCode, message: string, httpStatus?: number) {\n super(`[internal] ${message}`)\n this.name = 'DecisionModelError'\n this.code = code\n this.httpStatus = httpStatus\n }\n}\n\nexport const PROBABILITY_SUM_TOLERANCE = 0.02\n\nconst EXACT_SUM_EPSILON = 1e-9\n\nconst logger = createLogger('ai_assistant').child({ component: 'decision-model' })\n\nfunction logNormalized(questionName: string, sum: number): void {\n try {\n logger.debug('Normalized decision probabilities', { questionName, sum })\n } catch {\n return\n }\n}\n\nconst probabilitySchema = z.number().finite().min(0).max(1)\n\nconst rawChoiceAnswerSchema = z.object({\n type: z.literal('choice'),\n value: z.string(),\n probabilities: z.array(z.object({ key: z.string(), probability: probabilitySchema })),\n confidence: probabilitySchema.nullable(),\n})\n\nconst rawScoreAnswerSchema = z.object({\n type: z.literal('score'),\n probabilities: z.array(z.object({ level: z.number().int(), probability: probabilitySchema })),\n confidence: probabilitySchema.nullable(),\n})\n\nconst rawBinaryAnswerSchema = z.object({\n type: z.literal('binary'),\n probability: probabilitySchema,\n})\n\n/**\n * What an adapter hands back per question before the port validates it. For\n * `score`, the port derives `value` and `level` itself, so adapters only\n * supply the distribution.\n */\nexport const rawDecisionAnswerSchema = z.discriminatedUnion('type', [\n rawChoiceAnswerSchema,\n rawScoreAnswerSchema,\n rawBinaryAnswerSchema,\n])\n\nexport type RawDecisionAnswer = z.infer<typeof rawDecisionAnswerSchema>\n\nfunction invalidResponse(questionName: string, reason: string): DecisionModelError {\n return new DecisionModelError('invalid_response', `Decision answer \"${questionName}\" ${reason}`)\n}\n\nfunction normalizeDistribution(questionName: string, probabilities: number[]): number[] {\n const sum = probabilities.reduce((total, probability) => total + probability, 0)\n if (Math.abs(sum - 1) > PROBABILITY_SUM_TOLERANCE) {\n throw invalidResponse(questionName, 'has probabilities that do not sum to 1')\n }\n if (Math.abs(sum - 1) > EXACT_SUM_EPSILON) logNormalized(questionName, sum)\n return probabilities.map((probability) => probability / sum)\n}\n\nfunction argmax(values: number[]): number {\n return values.reduce((bestIndex, value, index) => (value > values[bestIndex] ? index : bestIndex), 0)\n}\n\nfunction normalizeChoice(\n questionName: string,\n question: Extract<DecisionQuestion, { type: 'choice' }>,\n answer: z.infer<typeof rawChoiceAnswerSchema>,\n): DecisionAnswer {\n const byKey = new Map(answer.probabilities.map((entry) => [entry.key, entry.probability]))\n const requestKeys = question.options.map((option) => option.key)\n if (byKey.size !== answer.probabilities.length || byKey.size !== requestKeys.length || requestKeys.some((key) => !byKey.has(key))) {\n throw invalidResponse(questionName, 'does not cover exactly the requested options')\n }\n if (!byKey.has(answer.value)) {\n throw invalidResponse(questionName, 'selected an option that was not requested')\n }\n const normalized = normalizeDistribution(questionName, requestKeys.map((key) => byKey.get(key) ?? 0))\n return {\n type: 'choice',\n value: answer.value,\n probabilities: requestKeys.map((key, index) => ({ key, probability: normalized[index] })),\n confidence: answer.confidence,\n }\n}\n\nfunction normalizeScore(\n questionName: string,\n question: Extract<DecisionQuestion, { type: 'score' }>,\n answer: z.infer<typeof rawScoreAnswerSchema>,\n): DecisionAnswer {\n const byLevel = new Map(answer.probabilities.map((entry) => [entry.level, entry.probability]))\n const levelCount = question.levels.length\n const levelIndexes = question.levels.map((_, index) => index)\n if (byLevel.size !== answer.probabilities.length || byLevel.size !== levelCount || levelIndexes.some((level) => !byLevel.has(level))) {\n throw invalidResponse(questionName, 'does not cover exactly the requested levels')\n }\n const normalized = normalizeDistribution(questionName, levelIndexes.map((level) => byLevel.get(level) ?? 0))\n const value = normalized.reduce((total, probability, level) => total + probability * level, 0)\n return {\n type: 'score',\n value,\n level: argmax(normalized),\n probabilities: levelIndexes.map((level) => ({ level, probability: normalized[level] })),\n confidence: answer.confidence,\n }\n}\n\n/**\n * Validates adapter output against the request and returns port answers:\n * every question answered with a matching type, probabilities in [0, 1],\n * key/level sets equal to the request's, the chosen value among them, and\n * `choice`/`score` distributions summing to 1 \u00B1 0.02 (then normalized to\n * exactly 1). `probabilities` come back in request order. Any violation\n * throws `DecisionModelError('invalid_response')`.\n */\nexport function normalizeDecisionAnswers(\n request: DecisionRequest,\n rawAnswers: Record<string, unknown>,\n): Record<string, DecisionAnswer> {\n const answers: Record<string, DecisionAnswer> = {}\n for (const [questionName, question] of Object.entries(request.questions)) {\n if (!Object.prototype.hasOwnProperty.call(rawAnswers, questionName)) {\n throw invalidResponse(questionName, 'is missing')\n }\n const parsed = rawDecisionAnswerSchema.safeParse(rawAnswers[questionName])\n if (!parsed.success) {\n throw invalidResponse(questionName, 'has an invalid shape')\n }\n const answer = parsed.data\n if (answer.type !== question.type) {\n throw invalidResponse(questionName, 'has a type that does not match the question')\n }\n if (answer.type === 'choice' && question.type === 'choice') {\n answers[questionName] = normalizeChoice(questionName, question, answer)\n } else if (answer.type === 'score' && question.type === 'score') {\n answers[questionName] = normalizeScore(questionName, question, answer)\n } else if (answer.type === 'binary') {\n answers[questionName] = { type: 'binary', probability: answer.probability }\n }\n }\n return answers\n}\n"],
|
|
5
|
+
"mappings": "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",
|
|
6
|
+
"names": []
|
|
7
|
+
}
|
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
import { countTokens } from "@open-mercato/shared/lib/ai/token-count";
|
|
2
|
+
import { createLogger } from "@open-mercato/shared/lib/logger";
|
|
3
|
+
import { parseNumberWithDefault } from "@open-mercato/shared/lib/number";
|
|
4
|
+
import { getTelemetryRuntime } from "@open-mercato/shared/lib/telemetry/runtime";
|
|
5
|
+
import {
|
|
6
|
+
createDecisionModelRegistry
|
|
7
|
+
} from "./decision-model-registry.js";
|
|
8
|
+
import { DecisionModelError, decisionRequestSchema, normalizeDecisionAnswers } from "./decision-model-schemas.js";
|
|
9
|
+
const DEFAULT_DECISION_TIMEOUT_MS = 5e3;
|
|
10
|
+
const MAX_DECISION_TIMEOUT_MS = 3e4;
|
|
11
|
+
const DEFAULT_DECISION_MAX_INPUT_TOKENS = 24e3;
|
|
12
|
+
const MAX_INPUT_TOKENS_ENV = "OM_DECISION_MAX_INPUT_TOKENS";
|
|
13
|
+
const ERROR_MESSAGES = {
|
|
14
|
+
unavailable: "Decision model is unavailable",
|
|
15
|
+
invalid_request: "Decision request is invalid",
|
|
16
|
+
auth: "Decision provider rejected the credentials",
|
|
17
|
+
insufficient_credits: "Decision provider credits are exhausted",
|
|
18
|
+
rate_limited: "Decision provider rate limit reached",
|
|
19
|
+
payload_too_large: "Decision request is too large",
|
|
20
|
+
timeout: "Decision request timed out",
|
|
21
|
+
aborted: "Decision request was aborted",
|
|
22
|
+
provider_error: "Decision provider failed",
|
|
23
|
+
invalid_response: "Decision provider returned an invalid response"
|
|
24
|
+
};
|
|
25
|
+
const REPORTED_ERROR_CODES = {
|
|
26
|
+
provider_error: "ai_assistant.decision_provider_error",
|
|
27
|
+
invalid_response: "ai_assistant.decision_invalid_response"
|
|
28
|
+
};
|
|
29
|
+
function messageFor(code) {
|
|
30
|
+
return ERROR_MESSAGES[code] ?? "Decision request failed";
|
|
31
|
+
}
|
|
32
|
+
function resolveDecisionTimeoutMs(timeoutMs) {
|
|
33
|
+
if (typeof timeoutMs !== "number" || !Number.isFinite(timeoutMs) || timeoutMs <= 0) {
|
|
34
|
+
return DEFAULT_DECISION_TIMEOUT_MS;
|
|
35
|
+
}
|
|
36
|
+
return Math.min(timeoutMs, MAX_DECISION_TIMEOUT_MS);
|
|
37
|
+
}
|
|
38
|
+
function resolveMaxInputTokens(env) {
|
|
39
|
+
const raw = env[MAX_INPUT_TOKENS_ENV];
|
|
40
|
+
return parseNumberWithDefault(typeof raw === "string" ? raw : void 0, DEFAULT_DECISION_MAX_INPUT_TOKENS, {
|
|
41
|
+
min: 1,
|
|
42
|
+
integer: true
|
|
43
|
+
});
|
|
44
|
+
}
|
|
45
|
+
function defaultReportError(error, context) {
|
|
46
|
+
getTelemetryRuntime()?.reportError(error, context);
|
|
47
|
+
}
|
|
48
|
+
function abortPromise(signal) {
|
|
49
|
+
let listener;
|
|
50
|
+
const promise = new Promise((_resolve, reject) => {
|
|
51
|
+
if (signal.aborted) {
|
|
52
|
+
reject(signal.reason);
|
|
53
|
+
return;
|
|
54
|
+
}
|
|
55
|
+
listener = () => reject(signal.reason);
|
|
56
|
+
signal.addEventListener("abort", listener, { once: true });
|
|
57
|
+
});
|
|
58
|
+
return {
|
|
59
|
+
promise,
|
|
60
|
+
cleanup: () => {
|
|
61
|
+
if (listener) signal.removeEventListener("abort", listener);
|
|
62
|
+
}
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
class DecisionFailure extends Error {
|
|
66
|
+
constructor(failure, origin) {
|
|
67
|
+
super(`[internal] ${failure.message}`);
|
|
68
|
+
this.name = "DecisionFailure";
|
|
69
|
+
this.failure = failure;
|
|
70
|
+
this.origin = origin;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
function fail(code, message, httpStatus, origin) {
|
|
74
|
+
const failure = { code, message: message ?? messageFor(code) };
|
|
75
|
+
if (httpStatus !== void 0) failure.httpStatus = httpStatus;
|
|
76
|
+
return new DecisionFailure(failure, origin);
|
|
77
|
+
}
|
|
78
|
+
function createDecisionModelService(deps = {}) {
|
|
79
|
+
let registry = deps.registry;
|
|
80
|
+
let logger = deps.logger;
|
|
81
|
+
const env = deps.env ?? process.env;
|
|
82
|
+
const reportError = deps.reportError ?? defaultReportError;
|
|
83
|
+
const now = deps.now ?? (() => Date.now());
|
|
84
|
+
const getRegistry = () => {
|
|
85
|
+
if (!registry) registry = createDecisionModelRegistry({ env });
|
|
86
|
+
return registry;
|
|
87
|
+
};
|
|
88
|
+
const getLogger = () => {
|
|
89
|
+
if (!logger) logger = createLogger("ai_assistant").child({ component: "decision-model" });
|
|
90
|
+
return logger;
|
|
91
|
+
};
|
|
92
|
+
const safeLog = (level, fields) => {
|
|
93
|
+
try {
|
|
94
|
+
getLogger()[level]("decision model call", fields);
|
|
95
|
+
} catch {
|
|
96
|
+
return;
|
|
97
|
+
}
|
|
98
|
+
};
|
|
99
|
+
const safeReport = (code, error) => {
|
|
100
|
+
const reportCode = REPORTED_ERROR_CODES[code];
|
|
101
|
+
if (!reportCode) return;
|
|
102
|
+
try {
|
|
103
|
+
reportError(error, { module: "ai_assistant", code: reportCode });
|
|
104
|
+
} catch {
|
|
105
|
+
return;
|
|
106
|
+
}
|
|
107
|
+
};
|
|
108
|
+
const getAvailability = (input) => {
|
|
109
|
+
try {
|
|
110
|
+
const resolution = getRegistry().resolve({ moduleId: input?.moduleId, env });
|
|
111
|
+
if (!resolution.ok) return { available: false, reason: resolution.reason };
|
|
112
|
+
return { available: true, provider: resolution.adapter.id, model: resolution.modelId };
|
|
113
|
+
} catch {
|
|
114
|
+
return { available: false, reason: "not_configured" };
|
|
115
|
+
}
|
|
116
|
+
};
|
|
117
|
+
const decide = async (input) => {
|
|
118
|
+
const startedAt = now();
|
|
119
|
+
let provider;
|
|
120
|
+
let model;
|
|
121
|
+
let questionCount = 0;
|
|
122
|
+
try {
|
|
123
|
+
const parsed = decisionRequestSchema.safeParse(input?.request);
|
|
124
|
+
if (!parsed.success) throw fail("invalid_request");
|
|
125
|
+
const request = parsed.data;
|
|
126
|
+
questionCount = Object.keys(request.questions).length;
|
|
127
|
+
let serialized;
|
|
128
|
+
try {
|
|
129
|
+
serialized = JSON.stringify({ state: request.state, questions: request.questions });
|
|
130
|
+
} catch {
|
|
131
|
+
throw fail("invalid_request", "Decision request state cannot be serialized");
|
|
132
|
+
}
|
|
133
|
+
if (countTokens(serialized ?? "") > resolveMaxInputTokens(env)) throw fail("payload_too_large");
|
|
134
|
+
const resolution = getRegistry().resolve({ moduleId: input.moduleId, env });
|
|
135
|
+
if (!resolution.ok) {
|
|
136
|
+
throw fail("unavailable", `Decision model unavailable: ${resolution.reason}`);
|
|
137
|
+
}
|
|
138
|
+
provider = resolution.adapter.id;
|
|
139
|
+
model = resolution.modelId;
|
|
140
|
+
const callerSignal = input.signal;
|
|
141
|
+
if (callerSignal?.aborted) throw fail("aborted");
|
|
142
|
+
const timeoutMs = resolveDecisionTimeoutMs(input.timeoutMs);
|
|
143
|
+
const timeoutController = new AbortController();
|
|
144
|
+
const timeoutReason = new DecisionModelError("timeout", `Decision request exceeded ${timeoutMs} ms`);
|
|
145
|
+
const timer = setTimeout(() => timeoutController.abort(timeoutReason), timeoutMs);
|
|
146
|
+
const combined = AbortSignal.any([timeoutController.signal, ...callerSignal ? [callerSignal] : []]);
|
|
147
|
+
const abort = abortPromise(combined);
|
|
148
|
+
let result;
|
|
149
|
+
try {
|
|
150
|
+
result = await Promise.race([
|
|
151
|
+
resolution.adapter.decide(request, { modelId: resolution.modelId, env, signal: combined }),
|
|
152
|
+
abort.promise
|
|
153
|
+
]);
|
|
154
|
+
} catch (error) {
|
|
155
|
+
if (combined.aborted) {
|
|
156
|
+
const abortCode = combined.reason === timeoutReason ? "timeout" : "aborted";
|
|
157
|
+
throw fail(abortCode, void 0, void 0, error);
|
|
158
|
+
}
|
|
159
|
+
if (error instanceof DecisionModelError) {
|
|
160
|
+
throw fail(error.code, void 0, error.httpStatus, error);
|
|
161
|
+
}
|
|
162
|
+
throw fail("provider_error", void 0, void 0, error);
|
|
163
|
+
} finally {
|
|
164
|
+
clearTimeout(timer);
|
|
165
|
+
abort.cleanup();
|
|
166
|
+
}
|
|
167
|
+
let answers;
|
|
168
|
+
try {
|
|
169
|
+
answers = normalizeDecisionAnswers(request, result.answers);
|
|
170
|
+
} catch (error) {
|
|
171
|
+
throw fail("invalid_response", void 0, void 0, error);
|
|
172
|
+
}
|
|
173
|
+
const durationMs = now() - startedAt;
|
|
174
|
+
const finalResult = { ...result, answers, durationMs };
|
|
175
|
+
safeLog("info", {
|
|
176
|
+
provider: finalResult.provider,
|
|
177
|
+
model: finalResult.model,
|
|
178
|
+
durationMs,
|
|
179
|
+
usage: finalResult.usage,
|
|
180
|
+
questionCount,
|
|
181
|
+
outcomeCodes: ["ok"]
|
|
182
|
+
});
|
|
183
|
+
return { ok: true, result: finalResult };
|
|
184
|
+
} catch (error) {
|
|
185
|
+
const failure = error instanceof DecisionFailure ? error.failure : fail("provider_error").failure;
|
|
186
|
+
const cause = error instanceof DecisionFailure ? error.origin ?? error : error;
|
|
187
|
+
safeLog("warn", {
|
|
188
|
+
provider: provider ?? null,
|
|
189
|
+
model: model ?? null,
|
|
190
|
+
durationMs: now() - startedAt,
|
|
191
|
+
usage: null,
|
|
192
|
+
questionCount,
|
|
193
|
+
outcomeCodes: [failure.code]
|
|
194
|
+
});
|
|
195
|
+
safeReport(failure.code, cause);
|
|
196
|
+
return { ok: false, error: failure };
|
|
197
|
+
}
|
|
198
|
+
};
|
|
199
|
+
return { getAvailability, decide };
|
|
200
|
+
}
|
|
201
|
+
export {
|
|
202
|
+
DEFAULT_DECISION_MAX_INPUT_TOKENS,
|
|
203
|
+
DEFAULT_DECISION_TIMEOUT_MS,
|
|
204
|
+
MAX_DECISION_TIMEOUT_MS,
|
|
205
|
+
createDecisionModelService,
|
|
206
|
+
resolveDecisionTimeoutMs
|
|
207
|
+
};
|
|
208
|
+
//# sourceMappingURL=decision-model-service.js.map
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
{
|
|
2
|
+
"version": 3,
|
|
3
|
+
"sources": ["../../../../src/modules/ai_assistant/lib/decision-model-service.ts"],
|
|
4
|
+
"sourcesContent": ["import type {\n DecisionErrorCode,\n DecisionRequest,\n DecisionResult,\n} from '@open-mercato/shared/lib/ai/decision-model'\nimport type { EnvLookup } from '@open-mercato/shared/lib/ai/llm-provider'\nimport { countTokens } from '@open-mercato/shared/lib/ai/token-count'\nimport { createLogger, type Logger } from '@open-mercato/shared/lib/logger'\nimport { parseNumberWithDefault } from '@open-mercato/shared/lib/number'\nimport { getTelemetryRuntime } from '@open-mercato/shared/lib/telemetry/runtime'\nimport {\n createDecisionModelRegistry,\n type DecisionModelRegistry,\n type DecisionModelUnavailableReason,\n} from './decision-model-registry'\nimport { DecisionModelError, decisionRequestSchema, normalizeDecisionAnswers } from './decision-model-schemas'\n\nexport const DEFAULT_DECISION_TIMEOUT_MS = 5000\nexport const MAX_DECISION_TIMEOUT_MS = 30000\nexport const DEFAULT_DECISION_MAX_INPUT_TOKENS = 24000\n\nconst MAX_INPUT_TOKENS_ENV = 'OM_DECISION_MAX_INPUT_TOKENS'\n\n/** @experimental Availability of the decision model for an optional module. */\nexport interface DecisionModelAvailability {\n available: boolean\n provider?: string\n model?: string\n reason?: DecisionModelUnavailableReason\n}\n\n/** @experimental Closed error shape returned by `decide`. */\nexport interface DecisionModelDecideError {\n code: DecisionErrorCode\n message: string\n httpStatus?: number\n}\n\n/** @experimental Discriminated result of `decide`; it never throws. */\nexport type DecisionModelDecideResult =\n | { ok: true; result: DecisionResult }\n | { ok: false; error: DecisionModelDecideError }\n\n/** @experimental Input of `decide`. */\nexport interface DecisionModelDecideInput {\n request: DecisionRequest\n moduleId?: string\n timeoutMs?: number\n signal?: AbortSignal\n}\n\n/** @experimental DI service `decisionModelService`. */\nexport interface DecisionModelService {\n getAvailability(input?: { moduleId?: string }): DecisionModelAvailability\n decide(input: DecisionModelDecideInput): Promise<DecisionModelDecideResult>\n}\n\n/** Minimal logger shape the service writes through. */\nexport type DecisionModelServiceLogger = Pick<Logger, 'info' | 'warn'>\n\n/** Error reporter the service calls on `provider_error` / `invalid_response`. */\nexport type DecisionModelReportError = (\n error: unknown,\n context: { module: string; code: string },\n) => void\n\n/** Optional dependencies of `createDecisionModelService`, mainly for tests and overrides. */\nexport interface DecisionModelServiceDeps {\n registry?: DecisionModelRegistry\n env?: EnvLookup\n logger?: DecisionModelServiceLogger\n reportError?: DecisionModelReportError\n now?: () => number\n}\n\nconst ERROR_MESSAGES: Record<DecisionErrorCode, string> = {\n unavailable: 'Decision model is unavailable',\n invalid_request: 'Decision request is invalid',\n auth: 'Decision provider rejected the credentials',\n insufficient_credits: 'Decision provider credits are exhausted',\n rate_limited: 'Decision provider rate limit reached',\n payload_too_large: 'Decision request is too large',\n timeout: 'Decision request timed out',\n aborted: 'Decision request was aborted',\n provider_error: 'Decision provider failed',\n invalid_response: 'Decision provider returned an invalid response',\n}\n\nconst REPORTED_ERROR_CODES: Partial<Record<DecisionErrorCode, string>> = {\n provider_error: 'ai_assistant.decision_provider_error',\n invalid_response: 'ai_assistant.decision_invalid_response',\n}\n\nfunction messageFor(code: DecisionErrorCode): string {\n return ERROR_MESSAGES[code] ?? 'Decision request failed'\n}\n\n/** Clamps a caller timeout to the port bounds: default 5 s, maximum 30 s. */\nexport function resolveDecisionTimeoutMs(timeoutMs: number | undefined): number {\n if (typeof timeoutMs !== 'number' || !Number.isFinite(timeoutMs) || timeoutMs <= 0) {\n return DEFAULT_DECISION_TIMEOUT_MS\n }\n return Math.min(timeoutMs, MAX_DECISION_TIMEOUT_MS)\n}\n\nfunction resolveMaxInputTokens(env: EnvLookup): number {\n const raw = env[MAX_INPUT_TOKENS_ENV]\n return parseNumberWithDefault(typeof raw === 'string' ? raw : undefined, DEFAULT_DECISION_MAX_INPUT_TOKENS, {\n min: 1,\n integer: true,\n })\n}\n\nfunction defaultReportError(error: unknown, context: { module: string; code: string }): void {\n getTelemetryRuntime()?.reportError(error, context)\n}\n\nfunction abortPromise(signal: AbortSignal): { promise: Promise<never>; cleanup: () => void } {\n let listener: (() => void) | undefined\n const promise = new Promise<never>((_resolve, reject) => {\n if (signal.aborted) {\n reject(signal.reason)\n return\n }\n listener = () => reject(signal.reason)\n signal.addEventListener('abort', listener, { once: true })\n })\n return {\n promise,\n cleanup: () => {\n if (listener) signal.removeEventListener('abort', listener)\n },\n }\n}\n\nclass DecisionFailure extends Error {\n readonly failure: DecisionModelDecideError\n readonly origin: unknown\n\n constructor(failure: DecisionModelDecideError, origin?: unknown) {\n super(`[internal] ${failure.message}`)\n this.name = 'DecisionFailure'\n this.failure = failure\n this.origin = origin\n }\n}\n\nfunction fail(code: DecisionErrorCode, message?: string, httpStatus?: number, origin?: unknown): DecisionFailure {\n const failure: DecisionModelDecideError = { code, message: message ?? messageFor(code) }\n if (httpStatus !== undefined) failure.httpStatus = httpStatus\n return new DecisionFailure(failure, origin)\n}\n\n/**\n * Creates the decision model service. `decide` never throws or rejects: every\n * failure becomes `{ ok: false, error }`. The request state, instructions,\n * criteria and session id are never logged.\n *\n * @experimental\n */\nexport function createDecisionModelService(deps: DecisionModelServiceDeps = {}): DecisionModelService {\n let registry: DecisionModelRegistry | undefined = deps.registry\n let logger: DecisionModelServiceLogger | undefined = deps.logger\n const env: EnvLookup = deps.env ?? process.env\n const reportError: DecisionModelReportError = deps.reportError ?? defaultReportError\n const now = deps.now ?? (() => Date.now())\n\n const getRegistry = (): DecisionModelRegistry => {\n if (!registry) registry = createDecisionModelRegistry({ env })\n return registry\n }\n\n const getLogger = (): DecisionModelServiceLogger => {\n if (!logger) logger = createLogger('ai_assistant').child({ component: 'decision-model' })\n return logger\n }\n\n const safeLog = (level: 'info' | 'warn', fields: Record<string, unknown>): void => {\n try {\n getLogger()[level]('decision model call', fields)\n } catch {\n return\n }\n }\n\n const safeReport = (code: DecisionErrorCode, error: unknown): void => {\n const reportCode = REPORTED_ERROR_CODES[code]\n if (!reportCode) return\n try {\n reportError(error, { module: 'ai_assistant', code: reportCode })\n } catch {\n return\n }\n }\n\n const getAvailability: DecisionModelService['getAvailability'] = (input) => {\n try {\n const resolution = getRegistry().resolve({ moduleId: input?.moduleId, env })\n if (!resolution.ok) return { available: false, reason: resolution.reason }\n return { available: true, provider: resolution.adapter.id, model: resolution.modelId }\n } catch {\n return { available: false, reason: 'not_configured' }\n }\n }\n\n const decide: DecisionModelService['decide'] = async (input) => {\n const startedAt = now()\n let provider: string | undefined\n let model: string | undefined\n let questionCount = 0\n try {\n const parsed = decisionRequestSchema.safeParse(input?.request)\n if (!parsed.success) throw fail('invalid_request')\n const request: DecisionRequest = parsed.data\n questionCount = Object.keys(request.questions).length\n\n let serialized: string | undefined\n try {\n serialized = JSON.stringify({ state: request.state, questions: request.questions })\n } catch {\n throw fail('invalid_request', 'Decision request state cannot be serialized')\n }\n if (countTokens(serialized ?? '') > resolveMaxInputTokens(env)) throw fail('payload_too_large')\n\n const resolution = getRegistry().resolve({ moduleId: input.moduleId, env })\n if (!resolution.ok) {\n throw fail('unavailable', `Decision model unavailable: ${resolution.reason}`)\n }\n provider = resolution.adapter.id\n model = resolution.modelId\n\n const callerSignal = input.signal\n if (callerSignal?.aborted) throw fail('aborted')\n\n const timeoutMs = resolveDecisionTimeoutMs(input.timeoutMs)\n const timeoutController = new AbortController()\n const timeoutReason = new DecisionModelError('timeout', `Decision request exceeded ${timeoutMs} ms`)\n const timer = setTimeout(() => timeoutController.abort(timeoutReason), timeoutMs)\n const combined = AbortSignal.any([timeoutController.signal, ...(callerSignal ? [callerSignal] : [])])\n const abort = abortPromise(combined)\n\n let result: DecisionResult\n try {\n result = await Promise.race([\n resolution.adapter.decide(request, { modelId: resolution.modelId, env, signal: combined }),\n abort.promise,\n ])\n } catch (error) {\n if (combined.aborted) {\n const abortCode: DecisionErrorCode = combined.reason === timeoutReason ? 'timeout' : 'aborted'\n throw fail(abortCode, undefined, undefined, error)\n }\n if (error instanceof DecisionModelError) {\n throw fail(error.code, undefined, error.httpStatus, error)\n }\n throw fail('provider_error', undefined, undefined, error)\n } finally {\n clearTimeout(timer)\n abort.cleanup()\n }\n\n let answers: DecisionResult['answers']\n try {\n answers = normalizeDecisionAnswers(request, result.answers)\n } catch (error) {\n throw fail('invalid_response', undefined, undefined, error)\n }\n\n const durationMs = now() - startedAt\n const finalResult: DecisionResult = { ...result, answers, durationMs }\n safeLog('info', {\n provider: finalResult.provider,\n model: finalResult.model,\n durationMs,\n usage: finalResult.usage,\n questionCount,\n outcomeCodes: ['ok'],\n })\n return { ok: true, result: finalResult }\n } catch (error) {\n const failure = error instanceof DecisionFailure ? error.failure : fail('provider_error').failure\n const cause = error instanceof DecisionFailure ? error.origin ?? error : error\n safeLog('warn', {\n provider: provider ?? null,\n model: model ?? null,\n durationMs: now() - startedAt,\n usage: null,\n questionCount,\n outcomeCodes: [failure.code],\n })\n safeReport(failure.code, cause)\n return { ok: false, error: failure }\n }\n }\n\n return { getAvailability, decide }\n}\n"],
|
|
5
|
+
"mappings": "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",
|
|
6
|
+
"names": []
|
|
7
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@open-mercato/ai-assistant",
|
|
3
|
-
"version": "0.10.1-develop.
|
|
3
|
+
"version": "0.10.1-develop.7690.1.9d41ec4115",
|
|
4
4
|
"license": "MIT",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"engines": {
|
|
@@ -100,16 +100,16 @@
|
|
|
100
100
|
"zod-to-json-schema": "^3.25.2"
|
|
101
101
|
},
|
|
102
102
|
"peerDependencies": {
|
|
103
|
-
"@open-mercato/shared": "0.10.1-develop.
|
|
104
|
-
"@open-mercato/ui": "0.10.1-develop.
|
|
103
|
+
"@open-mercato/shared": "0.10.1-develop.7690.1.9d41ec4115",
|
|
104
|
+
"@open-mercato/ui": "0.10.1-develop.7690.1.9d41ec4115",
|
|
105
105
|
"react": "^19.0.0",
|
|
106
106
|
"react-dom": "^19.0.0",
|
|
107
107
|
"zod": ">=3.23.0"
|
|
108
108
|
},
|
|
109
109
|
"devDependencies": {
|
|
110
|
-
"@open-mercato/cli": "0.10.1-develop.
|
|
111
|
-
"@open-mercato/shared": "0.10.1-develop.
|
|
112
|
-
"@open-mercato/ui": "0.10.1-develop.
|
|
110
|
+
"@open-mercato/cli": "0.10.1-develop.7690.1.9d41ec4115",
|
|
111
|
+
"@open-mercato/shared": "0.10.1-develop.7690.1.9d41ec4115",
|
|
112
|
+
"@open-mercato/ui": "0.10.1-develop.7690.1.9d41ec4115",
|
|
113
113
|
"@types/react": "^19.2.18",
|
|
114
114
|
"@types/react-dom": "^19.2.5",
|
|
115
115
|
"react": "19.2.8",
|
|
@@ -2,6 +2,7 @@ import { asFunction, asValue } from 'awilix'
|
|
|
2
2
|
import type { AwilixContainer } from 'awilix'
|
|
3
3
|
import { toolRegistry } from './lib/tool-registry'
|
|
4
4
|
import { createModerationService } from './lib/moderation'
|
|
5
|
+
import { createDecisionModelService } from './lib/decision-model-service'
|
|
5
6
|
import { createOpenCodeClient } from './lib/opencode-client'
|
|
6
7
|
|
|
7
8
|
export function register(container: AwilixContainer): void {
|
|
@@ -10,6 +11,9 @@ export function register(container: AwilixContainer): void {
|
|
|
10
11
|
// Input pre-moderation service (OpenAI /v1/moderations). Singleton +
|
|
11
12
|
// overridable by downstream apps via the module overrides DI seam.
|
|
12
13
|
moderationService: asFunction(() => createModerationService()).singleton(),
|
|
14
|
+
// Decision model port (typed answers with probabilities). Singleton that
|
|
15
|
+
// owns the only decision model registry; consumers resolve it soft-optionally.
|
|
16
|
+
decisionModelService: asFunction(() => createDecisionModelService()).singleton(),
|
|
13
17
|
// Injectable OpenCode client so the file-agent runner (and tests, via a
|
|
14
18
|
// fake client) resolve it from DI instead of constructing it inline.
|
|
15
19
|
// Production wiring uses the env-configured factory.
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
import type { DecisionQuestion, DecisionRequest } from '@open-mercato/shared/lib/ai/decision-model'
|
|
2
|
+
import { createFixtureDecisionAdapter } from '../decision-adapters/fixture'
|
|
3
|
+
|
|
4
|
+
function choiceQuestion(keys: string[]): DecisionQuestion {
|
|
5
|
+
return {
|
|
6
|
+
type: 'choice',
|
|
7
|
+
instructions: 'Pick the queue',
|
|
8
|
+
options: keys.map((key) => ({ key, criteria: `Queue ${key}` })),
|
|
9
|
+
}
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
async function decide(request: DecisionRequest) {
|
|
13
|
+
const adapter = createFixtureDecisionAdapter()
|
|
14
|
+
return adapter.decide(request, { modelId: 'fixture-model', signal: new AbortController().signal })
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
async function decideChoice(state: unknown, keys: string[]) {
|
|
18
|
+
const result = await decide({ state, questions: { route: choiceQuestion(keys) } })
|
|
19
|
+
const answer = result.answers.route
|
|
20
|
+
if (answer.type !== 'choice') throw new Error('[internal] expected a choice answer')
|
|
21
|
+
return answer
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
describe('createFixtureDecisionAdapter', () => {
|
|
25
|
+
it('exposes the fixture id and is always configured', () => {
|
|
26
|
+
const adapter = createFixtureDecisionAdapter()
|
|
27
|
+
expect(adapter.id).toBe('fixture')
|
|
28
|
+
expect(adapter.isConfigured()).toBe(true)
|
|
29
|
+
expect(adapter.isConfigured({})).toBe(true)
|
|
30
|
+
})
|
|
31
|
+
|
|
32
|
+
it('picks an option whose key appears as a whole word, case-insensitively', async () => {
|
|
33
|
+
const answer = await decideChoice({ ticket: 'Please send a REFUND now' }, ['billing', 'refund'])
|
|
34
|
+
expect(answer.value).toBe('refund')
|
|
35
|
+
expect(answer.probabilities).toEqual([
|
|
36
|
+
{ key: 'billing', probability: 0 },
|
|
37
|
+
{ key: 'refund', probability: 1 },
|
|
38
|
+
])
|
|
39
|
+
expect(answer.confidence).toBe(1)
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
it('does not match a key that only appears as a substring', async () => {
|
|
43
|
+
const answer = await decideChoice({ ticket: 'billing question' }, ['bill', 'other'])
|
|
44
|
+
expect(answer.probabilities).toEqual([
|
|
45
|
+
{ key: 'bill', probability: 0.5 },
|
|
46
|
+
{ key: 'other', probability: 0.5 },
|
|
47
|
+
])
|
|
48
|
+
})
|
|
49
|
+
|
|
50
|
+
it('lets the first matching option in option order win', async () => {
|
|
51
|
+
const answer = await decideChoice({ ticket: 'refund for billing' }, ['billing', 'refund', 'other'])
|
|
52
|
+
expect(answer.value).toBe('billing')
|
|
53
|
+
expect(answer.probabilities.map((entry) => entry.probability)).toEqual([1, 0, 0])
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
it('returns a uniform distribution with the first option when nothing matches', async () => {
|
|
57
|
+
const answer = await decideChoice({ ticket: 'hello' }, ['alpha', 'beta', 'gamma', 'delta'])
|
|
58
|
+
expect(answer.value).toBe('alpha')
|
|
59
|
+
expect(answer.probabilities.map((entry) => entry.probability)).toEqual([0.25, 0.25, 0.25, 0.25])
|
|
60
|
+
expect(answer.confidence).toBe(0.25)
|
|
61
|
+
})
|
|
62
|
+
|
|
63
|
+
it('treats an undefined state as empty', async () => {
|
|
64
|
+
const answer = await decideChoice(undefined, ['alpha', 'beta'])
|
|
65
|
+
expect(answer.value).toBe('alpha')
|
|
66
|
+
expect(answer.confidence).toBe(0.5)
|
|
67
|
+
})
|
|
68
|
+
|
|
69
|
+
it('matches keys containing hyphens and underscores as whole words', async () => {
|
|
70
|
+
const hyphen = await decideChoice({ queue: 'route to tier-2 now' }, ['tier', 'tier-2'])
|
|
71
|
+
expect(hyphen.value).toBe('tier-2')
|
|
72
|
+
const underscore = await decideChoice({ queue: 'escalate_now' }, ['escalate', 'escalate_now'])
|
|
73
|
+
expect(underscore.value).toBe('escalate_now')
|
|
74
|
+
const partial = await decideChoice({ queue: 'tier-22' }, ['tier-2', 'other'])
|
|
75
|
+
expect(partial.confidence).toBe(0.5)
|
|
76
|
+
})
|
|
77
|
+
|
|
78
|
+
it('answers score questions with a uniform distribution and derived value and level', async () => {
|
|
79
|
+
const result = await decide({
|
|
80
|
+
state: { text: 'anything' },
|
|
81
|
+
questions: { urgency: { type: 'score', instructions: 'Rate urgency', levels: ['low', 'medium', 'high'] } },
|
|
82
|
+
})
|
|
83
|
+
const answer = result.answers.urgency
|
|
84
|
+
if (answer.type !== 'score') throw new Error('[internal] expected a score answer')
|
|
85
|
+
expect(answer.value).toBeCloseTo(1)
|
|
86
|
+
expect(answer.level).toBe(0)
|
|
87
|
+
expect(answer.probabilities.map((entry) => entry.probability)).toEqual([1 / 3, 1 / 3, 1 / 3])
|
|
88
|
+
expect(answer.confidence).toBeCloseTo(1 / 3)
|
|
89
|
+
})
|
|
90
|
+
|
|
91
|
+
it('answers binary questions with probability 0.5', async () => {
|
|
92
|
+
const result = await decide({
|
|
93
|
+
state: {},
|
|
94
|
+
questions: { spam: { type: 'binary', instructions: 'Is it spam?', criteria: { true: 'spam', false: 'not spam' } } },
|
|
95
|
+
})
|
|
96
|
+
expect(result.answers.spam).toEqual({ type: 'binary', probability: 0.5 })
|
|
97
|
+
})
|
|
98
|
+
|
|
99
|
+
it('reports fixture provider metadata and null usage', async () => {
|
|
100
|
+
const result = await decide({ state: {}, questions: { route: choiceQuestion(['alpha', 'beta']) } })
|
|
101
|
+
expect(result.provider).toBe('fixture')
|
|
102
|
+
expect(result.model).toBe('fixture-model')
|
|
103
|
+
expect(result.usage).toEqual({ inputTokens: null, outputTokens: null, costUsd: null })
|
|
104
|
+
expect(result.durationMs).toBeGreaterThanOrEqual(0)
|
|
105
|
+
})
|
|
106
|
+
|
|
107
|
+
it('rethrows the abort reason when the signal is already aborted', async () => {
|
|
108
|
+
const controller = new AbortController()
|
|
109
|
+
const reason = new Error('[internal] cancelled')
|
|
110
|
+
controller.abort(reason)
|
|
111
|
+
const adapter = createFixtureDecisionAdapter()
|
|
112
|
+
await expect(
|
|
113
|
+
adapter.decide(
|
|
114
|
+
{ state: {}, questions: { route: choiceQuestion(['alpha', 'beta']) } },
|
|
115
|
+
{ modelId: 'fixture-model', signal: controller.signal },
|
|
116
|
+
),
|
|
117
|
+
).rejects.toBe(reason)
|
|
118
|
+
})
|
|
119
|
+
})
|
|
@@ -0,0 +1,203 @@
|
|
|
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 {
|
|
4
|
+
createDecisionModelRegistry,
|
|
5
|
+
decisionModuleModelEnvKey,
|
|
6
|
+
isDecisionFixtureEnabled,
|
|
7
|
+
} from '../decision-model-registry'
|
|
8
|
+
|
|
9
|
+
const fixtureOptIn: EnvLookup = { OM_DECISION_FIXTURE_ENABLED: '1', NODE_ENV: 'test' }
|
|
10
|
+
|
|
11
|
+
function stubAdapter(id: string, configured = true): DecisionModelAdapter {
|
|
12
|
+
return {
|
|
13
|
+
id,
|
|
14
|
+
isConfigured: () => configured,
|
|
15
|
+
decide: async () => {
|
|
16
|
+
throw new Error('[internal] not used')
|
|
17
|
+
},
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
describe('decisionModuleModelEnvKey', () => {
|
|
22
|
+
it('uppercases the module id and replaces non-alphanumerics with underscores', () => {
|
|
23
|
+
expect(decisionModuleModelEnvKey('workflows')).toBe('OM_DECISION_WORKFLOWS_MODEL')
|
|
24
|
+
expect(decisionModuleModelEnvKey('inbox-ops.x')).toBe('OM_DECISION_INBOX_OPS_X_MODEL')
|
|
25
|
+
expect(decisionModuleModelEnvKey('sales_2')).toBe('OM_DECISION_SALES_2_MODEL')
|
|
26
|
+
})
|
|
27
|
+
})
|
|
28
|
+
|
|
29
|
+
describe('isDecisionFixtureEnabled', () => {
|
|
30
|
+
it('requires the opt-in flag and a non-production NODE_ENV', () => {
|
|
31
|
+
expect(isDecisionFixtureEnabled({})).toBe(false)
|
|
32
|
+
expect(isDecisionFixtureEnabled({ OM_DECISION_FIXTURE_ENABLED: '1', NODE_ENV: 'test' })).toBe(true)
|
|
33
|
+
expect(isDecisionFixtureEnabled({ OM_DECISION_FIXTURE_ENABLED: 'true', NODE_ENV: 'development' })).toBe(true)
|
|
34
|
+
expect(isDecisionFixtureEnabled({ OM_DECISION_FIXTURE_ENABLED: '1' })).toBe(true)
|
|
35
|
+
expect(isDecisionFixtureEnabled({ OM_DECISION_FIXTURE_ENABLED: '0', NODE_ENV: 'test' })).toBe(false)
|
|
36
|
+
expect(isDecisionFixtureEnabled({ OM_DECISION_FIXTURE_ENABLED: '1', NODE_ENV: 'production' })).toBe(false)
|
|
37
|
+
})
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
describe('createDecisionModelRegistry', () => {
|
|
41
|
+
it('registers openrouter only by default', () => {
|
|
42
|
+
const registry = createDecisionModelRegistry({ env: {} })
|
|
43
|
+
expect(registry.list().map((adapter) => adapter.id)).toEqual(['openrouter'])
|
|
44
|
+
expect(registry.get('fixture')).toBeUndefined()
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
it('registers the fixture adapter under the double opt-in', () => {
|
|
48
|
+
const registry = createDecisionModelRegistry({ env: fixtureOptIn })
|
|
49
|
+
expect(registry.list().map((adapter) => adapter.id)).toEqual(['openrouter', 'fixture'])
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
it('does not register the fixture adapter in production', () => {
|
|
53
|
+
const registry = createDecisionModelRegistry({
|
|
54
|
+
env: { OM_DECISION_FIXTURE_ENABLED: '1', NODE_ENV: 'production' },
|
|
55
|
+
})
|
|
56
|
+
expect(registry.get('fixture')).toBeUndefined()
|
|
57
|
+
})
|
|
58
|
+
|
|
59
|
+
it('replaces an adapter re-registered under the same id and keeps registration order', () => {
|
|
60
|
+
const registry = createDecisionModelRegistry({ env: {} })
|
|
61
|
+
const first = stubAdapter('custom')
|
|
62
|
+
const second = stubAdapter('custom')
|
|
63
|
+
registry.register(first)
|
|
64
|
+
registry.register(stubAdapter('other'))
|
|
65
|
+
registry.register(second)
|
|
66
|
+
expect(registry.list().map((adapter) => adapter.id)).toEqual(['openrouter', 'custom', 'other'])
|
|
67
|
+
expect(registry.get('custom')).toBe(second)
|
|
68
|
+
})
|
|
69
|
+
|
|
70
|
+
describe('resolve', () => {
|
|
71
|
+
const registry = createDecisionModelRegistry({ env: {} })
|
|
72
|
+
|
|
73
|
+
it('reports not_configured when the provider is unset or blank', () => {
|
|
74
|
+
expect(registry.resolve({ env: {} })).toEqual({ ok: false, reason: 'not_configured' })
|
|
75
|
+
expect(registry.resolve({ env: { OM_DECISION_PROVIDER: ' ' } })).toEqual({ ok: false, reason: 'not_configured' })
|
|
76
|
+
})
|
|
77
|
+
|
|
78
|
+
it('reports unknown_provider for an unregistered provider', () => {
|
|
79
|
+
expect(registry.resolve({ env: { OM_DECISION_PROVIDER: 'acme' } })).toEqual({
|
|
80
|
+
ok: false,
|
|
81
|
+
reason: 'unknown_provider',
|
|
82
|
+
})
|
|
83
|
+
})
|
|
84
|
+
|
|
85
|
+
it('reports credentials_missing for openrouter without an API key', () => {
|
|
86
|
+
expect(
|
|
87
|
+
registry.resolve({ env: { OM_DECISION_PROVIDER: 'openrouter', OM_DECISION_MODEL: 'some/model' } }),
|
|
88
|
+
).toEqual({ ok: false, reason: 'credentials_missing' })
|
|
89
|
+
})
|
|
90
|
+
|
|
91
|
+
it('reports model_missing when the key is set but no model is', () => {
|
|
92
|
+
expect(
|
|
93
|
+
registry.resolve({ env: { OM_DECISION_PROVIDER: 'openrouter', OPENROUTER_API_KEY: 'key' } }),
|
|
94
|
+
).toEqual({ ok: false, reason: 'model_missing' })
|
|
95
|
+
})
|
|
96
|
+
|
|
97
|
+
it('resolves the global model', () => {
|
|
98
|
+
const resolution = registry.resolve({
|
|
99
|
+
env: { OM_DECISION_PROVIDER: 'openrouter', OPENROUTER_API_KEY: 'key', OM_DECISION_MODEL: ' global/model ' },
|
|
100
|
+
})
|
|
101
|
+
expect(resolution.ok).toBe(true)
|
|
102
|
+
if (!resolution.ok) return
|
|
103
|
+
expect(resolution.adapter.id).toBe('openrouter')
|
|
104
|
+
expect(resolution.modelId).toBe('global/model')
|
|
105
|
+
})
|
|
106
|
+
|
|
107
|
+
it('prefers the per-module model over the global one', () => {
|
|
108
|
+
const resolution = registry.resolve({
|
|
109
|
+
moduleId: 'workflows',
|
|
110
|
+
env: {
|
|
111
|
+
OM_DECISION_PROVIDER: 'openrouter',
|
|
112
|
+
OPENROUTER_API_KEY: 'key',
|
|
113
|
+
OM_DECISION_MODEL: 'global/model',
|
|
114
|
+
OM_DECISION_WORKFLOWS_MODEL: 'workflows/model',
|
|
115
|
+
},
|
|
116
|
+
})
|
|
117
|
+
expect(resolution).toMatchObject({ ok: true, modelId: 'workflows/model' })
|
|
118
|
+
})
|
|
119
|
+
|
|
120
|
+
it('falls back to the global model when the per-module key is unset or blank', () => {
|
|
121
|
+
const env: EnvLookup = {
|
|
122
|
+
OM_DECISION_PROVIDER: 'openrouter',
|
|
123
|
+
OPENROUTER_API_KEY: 'key',
|
|
124
|
+
OM_DECISION_MODEL: 'global/model',
|
|
125
|
+
}
|
|
126
|
+
expect(registry.resolve({ moduleId: 'workflows', env })).toMatchObject({ ok: true, modelId: 'global/model' })
|
|
127
|
+
expect(
|
|
128
|
+
registry.resolve({ moduleId: 'workflows', env: { ...env, OM_DECISION_WORKFLOWS_MODEL: ' ' } }),
|
|
129
|
+
).toMatchObject({ ok: true, modelId: 'global/model' })
|
|
130
|
+
})
|
|
131
|
+
|
|
132
|
+
it('reads the per-module key derived from a module id with non-alphanumerics', () => {
|
|
133
|
+
const resolution = registry.resolve({
|
|
134
|
+
moduleId: 'inbox-ops.x',
|
|
135
|
+
env: {
|
|
136
|
+
OM_DECISION_PROVIDER: 'openrouter',
|
|
137
|
+
OPENROUTER_API_KEY: 'key',
|
|
138
|
+
OM_DECISION_INBOX_OPS_X_MODEL: 'inbox/model',
|
|
139
|
+
},
|
|
140
|
+
})
|
|
141
|
+
expect(resolution).toMatchObject({ ok: true, modelId: 'inbox/model' })
|
|
142
|
+
})
|
|
143
|
+
|
|
144
|
+
it('tolerates provider id case and whitespace', () => {
|
|
145
|
+
const resolution = registry.resolve({
|
|
146
|
+
env: { OM_DECISION_PROVIDER: ' OpenRouter ', OPENROUTER_API_KEY: 'key', OM_DECISION_MODEL: 'global/model' },
|
|
147
|
+
})
|
|
148
|
+
expect(resolution).toMatchObject({ ok: true, modelId: 'global/model' })
|
|
149
|
+
})
|
|
150
|
+
|
|
151
|
+
it('reports fixture_disabled when fixture is selected without the opt-in', () => {
|
|
152
|
+
expect(
|
|
153
|
+
registry.resolve({ env: { OM_DECISION_PROVIDER: 'fixture', OM_DECISION_MODEL: 'fixture-model' } }),
|
|
154
|
+
).toEqual({ ok: false, reason: 'fixture_disabled' })
|
|
155
|
+
})
|
|
156
|
+
})
|
|
157
|
+
|
|
158
|
+
it('resolves the fixture adapter under the opt-in without an API key', () => {
|
|
159
|
+
const registry = createDecisionModelRegistry({ env: fixtureOptIn })
|
|
160
|
+
const resolution = registry.resolve({
|
|
161
|
+
env: { ...fixtureOptIn, OM_DECISION_PROVIDER: 'fixture', OM_DECISION_MODEL: 'fixture-model' },
|
|
162
|
+
})
|
|
163
|
+
expect(resolution.ok).toBe(true)
|
|
164
|
+
if (!resolution.ok) return
|
|
165
|
+
expect(resolution.adapter.id).toBe('fixture')
|
|
166
|
+
expect(resolution.modelId).toBe('fixture-model')
|
|
167
|
+
})
|
|
168
|
+
|
|
169
|
+
it('reports fixture_disabled when the opt-in is set under production', () => {
|
|
170
|
+
const env: EnvLookup = {
|
|
171
|
+
OM_DECISION_FIXTURE_ENABLED: '1',
|
|
172
|
+
NODE_ENV: 'production',
|
|
173
|
+
OM_DECISION_PROVIDER: 'fixture',
|
|
174
|
+
OM_DECISION_MODEL: 'fixture-model',
|
|
175
|
+
}
|
|
176
|
+
const registry = createDecisionModelRegistry({ env })
|
|
177
|
+
expect(registry.resolve()).toEqual({ ok: false, reason: 'fixture_disabled' })
|
|
178
|
+
})
|
|
179
|
+
|
|
180
|
+
it('uses the creation env when resolve receives no env', () => {
|
|
181
|
+
const registry = createDecisionModelRegistry({
|
|
182
|
+
env: { OM_DECISION_PROVIDER: 'openrouter', OPENROUTER_API_KEY: 'key', OM_DECISION_MODEL: 'global/model' },
|
|
183
|
+
})
|
|
184
|
+
expect(registry.resolve()).toMatchObject({ ok: true, modelId: 'global/model' })
|
|
185
|
+
})
|
|
186
|
+
|
|
187
|
+
it('resolves a custom registered adapter by id', () => {
|
|
188
|
+
const registry = createDecisionModelRegistry({ env: {} })
|
|
189
|
+
const custom = stubAdapter('acme')
|
|
190
|
+
registry.register(custom)
|
|
191
|
+
const resolution = registry.resolve({ env: { OM_DECISION_PROVIDER: 'ACME', OM_DECISION_MODEL: 'acme-1' } })
|
|
192
|
+
expect(resolution).toEqual({ ok: true, adapter: custom, modelId: 'acme-1' })
|
|
193
|
+
})
|
|
194
|
+
|
|
195
|
+
it('reports credentials_missing for an unconfigured custom adapter', () => {
|
|
196
|
+
const registry = createDecisionModelRegistry({ env: {} })
|
|
197
|
+
registry.register(stubAdapter('acme', false))
|
|
198
|
+
expect(registry.resolve({ env: { OM_DECISION_PROVIDER: 'acme', OM_DECISION_MODEL: 'acme-1' } })).toEqual({
|
|
199
|
+
ok: false,
|
|
200
|
+
reason: 'credentials_missing',
|
|
201
|
+
})
|
|
202
|
+
})
|
|
203
|
+
})
|