dsh-decision-layer 0.7.1 → 0.7.3

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 (61) hide show
  1. package/README.en.md +14 -14
  2. package/README.md +16 -16
  3. package/docs/en/DETAIL.md +2 -2
  4. package/docs/zh/DETAIL.md +2 -2
  5. package/lib/client.js +54 -23
  6. package/lib/host/backend.d.ts +15 -0
  7. package/lib/host/backend.js +149 -0
  8. package/lib/host/config-store.d.ts +12 -0
  9. package/lib/host/config-store.js +254 -0
  10. package/lib/host/danger-gate.d.ts +45 -0
  11. package/lib/host/danger-gate.js +196 -0
  12. package/lib/host/decision-context.d.ts +21 -0
  13. package/lib/host/decision-context.js +187 -0
  14. package/lib/host/excerpt.d.ts +26 -0
  15. package/lib/host/excerpt.js +76 -0
  16. package/lib/host/index.d.ts +6 -0
  17. package/lib/host/index.js +287 -0
  18. package/lib/host/log-analyze.d.ts +121 -0
  19. package/lib/host/log-analyze.js +254 -0
  20. package/lib/host/log-read.d.ts +4 -0
  21. package/lib/host/log-read.js +34 -0
  22. package/lib/host/log-store.d.ts +15 -0
  23. package/lib/host/log-store.js +98 -0
  24. package/lib/host/loop-guard.d.ts +10 -0
  25. package/lib/host/loop-guard.js +52 -0
  26. package/lib/host/manager-api.d.ts +20 -0
  27. package/lib/host/manager-api.js +174 -0
  28. package/lib/host/narrowing.d.ts +51 -0
  29. package/lib/host/narrowing.js +214 -0
  30. package/lib/host/quality-trend.d.ts +14 -0
  31. package/lib/host/quality-trend.js +22 -0
  32. package/lib/host/self-check.d.ts +28 -0
  33. package/lib/host/self-check.js +111 -0
  34. package/lib/host/session-state.d.ts +19 -0
  35. package/lib/host/session-state.js +289 -0
  36. package/lib/host/state-store.d.ts +14 -0
  37. package/lib/host/state-store.js +109 -0
  38. package/lib/host/steer-once.d.ts +13 -0
  39. package/lib/host/steer-once.js +25 -0
  40. package/lib/host/task-completion.d.ts +30 -0
  41. package/lib/host/task-completion.js +166 -0
  42. package/lib/host/types.d.ts +365 -0
  43. package/lib/host/types.js +11 -0
  44. package/package.json +17 -10
  45. package/service/.gitkeep +0 -0
  46. package/service/backend.mjs +0 -127
  47. package/service/config-store.mjs +0 -311
  48. package/service/danger-gate.mjs +0 -182
  49. package/service/dsh.mjs +0 -288
  50. package/service/log-analyze.mjs +0 -257
  51. package/service/log-read.mjs +0 -27
  52. package/service/log-store.mjs +0 -82
  53. package/service/loop-guard.mjs +0 -51
  54. package/service/manager-api.mjs +0 -137
  55. package/service/narrowing.mjs +0 -226
  56. package/service/quality-trend.mjs +0 -29
  57. package/service/self-check.mjs +0 -131
  58. package/service/session-state.mjs +0 -260
  59. package/service/state-store.mjs +0 -100
  60. package/service/steer-once.mjs +0 -25
  61. package/service/task-completion.mjs +0 -163
@@ -0,0 +1,365 @@
1
+ /**
2
+ * Shared types and guards for the host half.
3
+ *
4
+ * Three kinds of shape appear here, and they are deliberately kept apart:
5
+ * - wire contracts (EvaluateInput / EvaluateResult) — the one protocol with the
6
+ * adjudication backend, validated in backend.ts;
7
+ * - configuration (ResolvedConfig / *Settings) — validated in config-store.ts;
8
+ * - host runtime surfaces (HostContext, ToolExec, AgentLike) — our own
9
+ * structural view of what the Cordis host hands us. They are declared here
10
+ * instead of imported from @deepseek-ai/* so a host minor upgrade cannot
11
+ * break compilation: every field is optional and every boundary re-validates.
12
+ */
13
+ import type { IncomingMessage, ServerResponse } from 'node:http';
14
+ export type JsonObject = Record<string, unknown>;
15
+ export declare const isObject: (value: unknown) => value is JsonObject;
16
+ export declare const isProbability: (value: unknown) => value is number;
17
+ /** The stable reason code a backend failure carries, or a generic fallback. */
18
+ export declare const errorReason: (error: unknown, fallback?: string) => string;
19
+ export type ChoiceQuestion = {
20
+ type: 'choice';
21
+ instructions: unknown;
22
+ criteria: Record<string, unknown>;
23
+ };
24
+ export type ScoreQuestion = {
25
+ type: 'score';
26
+ instructions: unknown;
27
+ criteria: readonly unknown[];
28
+ };
29
+ export type NoulQuestion = {
30
+ type: 'noul';
31
+ instructions: unknown;
32
+ criteria?: unknown;
33
+ };
34
+ export type Question = ChoiceQuestion | ScoreQuestion | NoulQuestion;
35
+ export type QuestionMap = Record<string, Question>;
36
+ export type EvaluateInput = {
37
+ state: unknown;
38
+ questions: QuestionMap;
39
+ };
40
+ export type NoulAnswer = {
41
+ type: 'noul';
42
+ noul: number;
43
+ };
44
+ export type ScoredAnswer = {
45
+ confidence: number;
46
+ probabilities: Record<string, number>;
47
+ };
48
+ export type ScoreAnswer = ScoredAnswer & {
49
+ type: 'score';
50
+ score: number;
51
+ };
52
+ export type ChoiceAnswer = ScoredAnswer & {
53
+ type: 'choice';
54
+ choice: string;
55
+ };
56
+ export type Answer = NoulAnswer | ScoreAnswer | ChoiceAnswer;
57
+ export type AnswerMap = Record<string, Answer>;
58
+ export type EvaluateResult = {
59
+ model: string;
60
+ answers: AnswerMap;
61
+ usage?: {
62
+ input_tokens: number;
63
+ output_tokens: number;
64
+ };
65
+ };
66
+ export type Evaluator = (input: EvaluateInput, options?: {
67
+ signal?: AbortSignal;
68
+ }) => Promise<EvaluateResult>;
69
+ export type CheckMode = 'observe' | 'steer';
70
+ export type NarrowMode = 'observe' | 'enforce';
71
+ export type BatchMode = 'auto' | 'single' | 'split';
72
+ export type FeatureKey = 'gate' | 'check' | 'narrow' | 'complete';
73
+ export type Severity = 'normal' | 'warn' | 'severe';
74
+ export type DangerRuleAdditions = {
75
+ addToolNames?: string[];
76
+ removeToolNames?: string[];
77
+ addCommandPatterns?: string[];
78
+ addPathPatterns?: string[];
79
+ };
80
+ /** Compiled rules: the built-in patterns plus the configured additions. */
81
+ export type DangerRuleSet = {
82
+ toolNames: readonly string[];
83
+ commandPatterns: readonly RegExp[];
84
+ pathPatterns: readonly RegExp[];
85
+ };
86
+ export type GateSettings = {
87
+ minConfidence?: number;
88
+ };
89
+ export type CheckSettings = {
90
+ mode?: CheckMode;
91
+ rubric?: string[];
92
+ lowScoreThreshold?: number;
93
+ minConfidence?: number;
94
+ trendMinConfidence?: number;
95
+ trendRun?: number;
96
+ trendSevereRun?: number;
97
+ };
98
+ export type NarrowSettings = {
99
+ mode?: NarrowMode;
100
+ threshold?: number;
101
+ keepPrefixes?: string[];
102
+ maxCandidates?: number;
103
+ batchMode?: BatchMode;
104
+ };
105
+ export type CompleteSettings = {
106
+ mode?: CheckMode;
107
+ minConditions?: number;
108
+ minConfidence?: number;
109
+ };
110
+ export type Features = Partial<Record<FeatureKey, boolean>>;
111
+ /** What the backend needs from the config to make one request. */
112
+ export type BackendConfig = {
113
+ url: string;
114
+ apiKey: string;
115
+ model: string;
116
+ httpApprovedUrl: string;
117
+ };
118
+ /** Fully resolved configuration; every field the decision points read. */
119
+ export type ResolvedConfig = {
120
+ url: string;
121
+ apiKey: string;
122
+ model: string;
123
+ httpApprovedUrl: string;
124
+ dangerRules?: DangerRuleAdditions;
125
+ gateSettings?: GateSettings;
126
+ checkSettings?: CheckSettings;
127
+ narrowSettings?: NarrowSettings;
128
+ completeSettings?: CompleteSettings;
129
+ features?: Features;
130
+ };
131
+ /** What the config page may read back: the API key is reduced to a flag. */
132
+ export type ConfigView = {
133
+ url: string;
134
+ model: string;
135
+ apiKeySet: boolean;
136
+ httpApprovedUrl: string;
137
+ dangerRules?: DangerRuleAdditions;
138
+ gateSettings?: GateSettings;
139
+ checkSettings?: CheckSettings;
140
+ narrowSettings?: NarrowSettings;
141
+ completeSettings?: CompleteSettings;
142
+ features?: Features;
143
+ };
144
+ /** The quality-trend knobs, shared by the live path and the log backtest. */
145
+ export type TrendConfig = {
146
+ trendMinConfidence?: number;
147
+ trendRun?: number;
148
+ trendSevereRun?: number;
149
+ lowScoreThreshold?: number;
150
+ };
151
+ export type LogKind = 'gate' | 'check' | 'narrow' | 'complete';
152
+ /**
153
+ * One decision-log row. Fields are optional because rows are also read back
154
+ * from disk: persisted snapshots are untrusted input, and only `kind` is
155
+ * enforced before a row is accepted.
156
+ */
157
+ export type LogRow = {
158
+ id?: string;
159
+ at?: number;
160
+ kind: LogKind;
161
+ outcome?: string;
162
+ tool?: string;
163
+ suggestion?: string;
164
+ action?: string;
165
+ reason?: string;
166
+ subject?: string;
167
+ task?: string;
168
+ turn?: number;
169
+ step?: number;
170
+ command?: string;
171
+ path?: string;
172
+ mode?: string;
173
+ score?: number;
174
+ confidence?: number;
175
+ dropped?: number;
176
+ kept?: number;
177
+ applied?: boolean;
178
+ candidates?: number;
179
+ conditions?: number;
180
+ satisfied?: number;
181
+ unsatisfied?: number;
182
+ insufficient?: number;
183
+ steered?: boolean;
184
+ lowConfidence?: boolean;
185
+ tools?: string[];
186
+ };
187
+ /** The research-log sink record: the panel row plus its session tag. */
188
+ export type SessionSinkRow = LogRow & {
189
+ sessionId: string;
190
+ };
191
+ export type SessionCounters = {
192
+ attempts: number;
193
+ failures: number;
194
+ };
195
+ export type GateCounters = SessionCounters & {
196
+ ask: number;
197
+ deny: number;
198
+ allow: number;
199
+ actual: {
200
+ allow: number;
201
+ deny: number;
202
+ error: number;
203
+ };
204
+ };
205
+ export type CheckCounters = SessionCounters & {
206
+ low: number;
207
+ run: number;
208
+ severity: Severity;
209
+ };
210
+ export type NarrowCounters = SessionCounters & {
211
+ applied: number;
212
+ dropped: number;
213
+ };
214
+ export type CompleteCounters = SessionCounters & {
215
+ unsatisfied: number;
216
+ satisfied: number;
217
+ insufficient: number;
218
+ steered: number;
219
+ };
220
+ /** Live per-session state (the durable snapshot's payload). */
221
+ export type SessionState = {
222
+ enabled: boolean;
223
+ attempts: number;
224
+ failures: number;
225
+ gate: GateCounters;
226
+ check: CheckCounters;
227
+ narrow: NarrowCounters;
228
+ complete: CompleteCounters;
229
+ log: LogRow[];
230
+ };
231
+ /** The panel-facing projection; sections without attempts are omitted. */
232
+ export type SessionSnapshot = {
233
+ enabled: boolean;
234
+ hasAutomaticDecisions: boolean;
235
+ attempts: number;
236
+ failures: number;
237
+ gate?: GateCounters;
238
+ check?: {
239
+ attempts: number;
240
+ failures: number;
241
+ low: number;
242
+ trend: {
243
+ run: number;
244
+ severity: Severity;
245
+ };
246
+ };
247
+ narrow?: NarrowCounters;
248
+ complete?: CompleteCounters;
249
+ log?: LogRow[];
250
+ };
251
+ /** The counter shape rebuilt from persisted JSONL, without the master switch. */
252
+ export type SessionSeed = Omit<SessionState, 'enabled'>;
253
+ /**
254
+ * The slice of the session store the decision points use. Every method is
255
+ * optional: a test or an older host may inject only the parts it cares about.
256
+ */
257
+ export type SessionsFace = {
258
+ snapshot: (id: string) => SessionSnapshot;
259
+ log?: (id: string, entry: Record<string, unknown>) => void;
260
+ record?: (id: string, kind: 'gate', outcome: string) => void;
261
+ recordActual?: (id: string, outcome: 'allow' | 'deny' | 'error') => void;
262
+ recordCheck?: (id: string, outcome: string, trend?: Record<string, unknown>) => void;
263
+ recordNarrow?: (id: string, outcome: string, dropped: number, applied: boolean) => void;
264
+ recordComplete?: (id: string, outcome: string, detail?: Record<string, unknown>) => void;
265
+ ensureLoaded?: (id: string) => Promise<unknown>;
266
+ setEnabled?: (id: string, enabled: boolean) => void;
267
+ };
268
+ export type ToolCandidate = {
269
+ name: string;
270
+ description?: string;
271
+ };
272
+ /** Bounded, masked data for adjudication; background is never authorization. */
273
+ export type ToolEvidence = {
274
+ tool: string;
275
+ outcome: 'ok' | 'error';
276
+ command?: string;
277
+ path?: string;
278
+ result?: string;
279
+ };
280
+ export type DecisionContext = {
281
+ request: string;
282
+ background: {
283
+ role: 'user' | 'assistant';
284
+ text: string;
285
+ }[];
286
+ progress: ToolEvidence[];
287
+ };
288
+ export type DecisionSnapshot = {
289
+ context: DecisionContext;
290
+ conditionsText: string;
291
+ evidence: ToolEvidence[];
292
+ };
293
+ export type AgentLike = {
294
+ session?: {
295
+ id?: string;
296
+ deriveMessages?: () => unknown;
297
+ };
298
+ steer?: (message: unknown) => unknown;
299
+ ctx?: {
300
+ tools?: {
301
+ restrict?: (options: {
302
+ allow: string[];
303
+ }) => (() => void) | undefined;
304
+ };
305
+ };
306
+ };
307
+ export type ToolExec = {
308
+ name?: string;
309
+ arguments?: unknown;
310
+ agent?: AgentLike;
311
+ signal?: AbortSignal;
312
+ [key: string]: unknown;
313
+ };
314
+ export type NextStep = () => unknown;
315
+ export type ToolSchema = {
316
+ name: string;
317
+ description?: string;
318
+ };
319
+ export type ToolRegistration = {
320
+ name: string;
321
+ description: string;
322
+ parameters: Record<string, unknown>;
323
+ output: Record<string, unknown>;
324
+ timeoutMs?: number;
325
+ execute: (args: unknown, exec: ToolExec) => unknown;
326
+ };
327
+ export type SkillRegistration = {
328
+ name: string;
329
+ description: string;
330
+ content: string;
331
+ source: string;
332
+ };
333
+ export type ManagerRoute = {
334
+ kind: 'exact';
335
+ path: string;
336
+ handler: (req: IncomingMessage, res: ServerResponse) => Promise<void>;
337
+ };
338
+ export type WebServerFace = {
339
+ port: number;
340
+ host?: string;
341
+ register: (route: ManagerRoute) => () => void;
342
+ };
343
+ export type WebFace = {
344
+ webServer: WebServerFace;
345
+ };
346
+ export type ToolsFace = {
347
+ guard?: (guard: (exec: ToolExec) => string | undefined) => unknown;
348
+ register: (tool: ToolRegistration) => unknown;
349
+ schemas?: (agent?: AgentLike) => unknown;
350
+ };
351
+ export type SkillsFace = {
352
+ register: (skill: SkillRegistration) => unknown;
353
+ };
354
+ /**
355
+ * The Cordis context our apply() needs. Handlers are typed per call site
356
+ * because each event carries a different payload; `never[]` here keeps them
357
+ * assignable without leaking `any` into the implementation.
358
+ */
359
+ export type HostContext = {
360
+ on: (event: string, handler: (...args: never[]) => unknown) => (() => void) | undefined;
361
+ effect?: (install: () => (() => void) | void) => unknown;
362
+ tools: ToolsFace;
363
+ skills: SkillsFace;
364
+ inject: (keys: string[], install: (web: WebFace) => (() => void) | void) => unknown;
365
+ };
@@ -0,0 +1,11 @@
1
+ const isObject = (value) => value !== null && typeof value === "object" && !Array.isArray(value);
2
+ const isProbability = (value) => typeof value === "number" && Number.isFinite(value) && value >= 0 && value <= 1;
3
+ const errorReason = (error, fallback = "unreachable") => {
4
+ const reason = error?.reason;
5
+ return typeof reason === "string" ? reason : fallback;
6
+ };
7
+ export {
8
+ errorReason,
9
+ isObject,
10
+ isProbability
11
+ };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-decision-layer",
3
- "version": "0.7.1",
3
+ "version": "0.7.3",
4
4
  "description": "A structured decision layer for the DeepSeek Harness agent loop: a danger-call gate, output self-check, tool narrowing, and loop guard backed by a pluggable adjudication model.",
5
5
  "keywords": [
6
6
  "dsh",
@@ -14,11 +14,11 @@
14
14
  "license": "MIT",
15
15
  "type": "module",
16
16
  "icon": "./icon.svg",
17
- "main": "./service/dsh.mjs",
17
+ "main": "./lib/host/index.js",
18
18
  "files": [
19
- "service",
20
- "skills",
19
+ "lib/host",
21
20
  "lib/client.js",
21
+ "skills",
22
22
  "locale/*.json",
23
23
  "docs/zh/DETAIL.md",
24
24
  "docs/en/DETAIL.md",
@@ -36,20 +36,25 @@
36
36
  "url": "https://github.com/xbzbing/dsh-decision-layer.git"
37
37
  },
38
38
  "scripts": {
39
- "test": "node --test tests/*.test.mjs",
40
- "build:client": "tsc -p tsconfig.client.json && node scripts/build-client.mjs",
41
- "check": "npm run build:client && npm test",
39
+ "build": "node build.mjs",
40
+ "typecheck": "tsc -p tsconfig.host.json --noEmit && tsc -p tsconfig.client.json",
41
+ "test": "node build.mjs && node --test tests/*.test.mjs",
42
+ "check": "npm test && npm run pack:assert",
42
43
  "pack:assert": "node scripts/assert-pack.mjs"
43
44
  },
44
45
  "exports": {
45
- ".": "./service/dsh.mjs",
46
+ ".": {
47
+ "types": "./lib/host/index.d.ts",
48
+ "default": "./lib/host/index.js"
49
+ },
46
50
  "./client": "./lib/client.js",
47
51
  "./locale/*.json": "./locale/*.json",
48
52
  "./package.json": "./package.json"
49
53
  },
50
54
  "peerDependencies": {
51
55
  "@deepseek-ai/cordis": "~4.0.4",
52
- "@deepseek-ai/dsh": ">=0.1.7-rc.2"
56
+ "@deepseek-ai/dsh": ">=0.1.7-rc.2",
57
+ "@deepseek-ai/dsh-llm": ">=0.1.7-rc.2"
53
58
  },
54
59
  "devDependencies": {
55
60
  "@deepseek-ai/dsh-client-locale": "0.1.7-rc.2",
@@ -58,6 +63,7 @@
58
63
  "@deepseek-ai/dsh-client-ui-renderer": "0.1.7-rc.2",
59
64
  "@deepseek-ai/dsh-client-ui-slots": "0.1.7-rc.2",
60
65
  "@testing-library/react": "^16.3.3",
66
+ "@types/node": "^20.19.0",
61
67
  "@types/react": "~18.3.1",
62
68
  "@types/react-dom": "~18.3.0",
63
69
  "esbuild": "^0.25.12",
@@ -80,5 +86,6 @@
80
86
  ],
81
87
  "platform": "web"
82
88
  }
83
- }
89
+ },
90
+ "types": "./lib/host/index.d.ts"
84
91
  }
package/service/.gitkeep DELETED
File without changes
@@ -1,127 +0,0 @@
1
- import { setTimeout as wait } from 'node:timers/promises';
2
- import { DEFAULT_MODEL } from './config-store.mjs';
3
-
4
- const REQUEST_LIMIT = 256 * 1024;
5
- const RESPONSE_LIMIT = 1024 * 1024;
6
- const isObject = value => value !== null && typeof value === 'object' && !Array.isArray(value);
7
- const isContent = value => typeof value === 'string' ? value.trim().length > 0 : isObject(value) || Array.isArray(value);
8
- const probability = value => typeof value === 'number' && Number.isFinite(value) && value >= 0 && value <= 1;
9
-
10
- // A backend failure carries a stable `reason` code so callers can surface the
11
- // specific cause (unreachable / http-error / invalid-response / config) instead
12
- // of a single generic error.
13
- function backendError(reason, message) {
14
- const error = new Error(message);
15
- error.reason = reason;
16
- return error;
17
- }
18
-
19
- export function validateRequest(input) {
20
- if (!isObject(input) || !isContent(input.state) || !isObject(input.questions)) throw new Error('Invalid state or questions');
21
- const questions = Object.entries(input.questions);
22
- if (questions.length < 1 || questions.length > 64) throw new Error('Invalid question count');
23
- for (const [id, question] of questions) {
24
- if (!id.trim() || id.length > 160 || !isObject(question) || !isContent(question.instructions)) throw new Error('Invalid question');
25
- if (question.type === 'choice') {
26
- if (!isObject(question.criteria) || Object.keys(question.criteria).length < 2 || Object.keys(question.criteria).length > 255 ||
27
- Object.entries(question.criteria).some(([key, value]) => !key.trim() || (value !== null && !isContent(value)))) throw new Error('Invalid choice criteria');
28
- } else if (question.type === 'score') {
29
- if (!Array.isArray(question.criteria) || question.criteria.length < 2 || question.criteria.length > 10 || !question.criteria.every(isContent)) throw new Error('Invalid score criteria');
30
- } else if (question.type !== 'noul' || (question.criteria !== undefined && (!isObject(question.criteria) || Object.keys(question.criteria).some(key => !['true', 'false'].includes(key))))) {
31
- throw new Error('Invalid question type or criteria');
32
- }
33
- }
34
- let encoded;
35
- try {
36
- encoded = JSON.stringify(input, (key, value) => {
37
- if (typeof value === 'function' || typeof value === 'symbol' || typeof value === 'bigint' || (typeof value === 'number' && !Number.isFinite(value))) throw new Error('Invalid JSON');
38
- return value;
39
- });
40
- } catch { throw new Error('Invalid JSON payload'); }
41
- if (Buffer.byteLength(encoded) > REQUEST_LIMIT) throw new Error('Request too large');
42
- return { state: input.state, questions: input.questions };
43
- }
44
-
45
- function validateAnswer(value, question) {
46
- if (!isObject(value) || value.type !== question.type) throw backendError('invalid-response', 'Invalid response answer');
47
- if (question.type === 'noul') {
48
- if (!probability(value.noul)) throw backendError('invalid-response', 'Invalid response probability');
49
- return { type: 'noul', noul: value.noul };
50
- }
51
- const keys = question.type === 'choice' ? Object.keys(question.criteria) : question.criteria.map((_, i) => String(i));
52
- if (!isObject(value.probabilities) || !probability(value.confidence) ||
53
- Object.keys(value.probabilities).length !== keys.length || keys.some(key => !probability(value.probabilities[key])) ||
54
- Math.abs(keys.reduce((sum, key) => sum + value.probabilities[key], 0) - 1) > 0.02) throw backendError('invalid-response', 'Invalid response probabilities');
55
- const common = { type: value.type, confidence: value.confidence, probabilities: Object.fromEntries(keys.map(key => [key, value.probabilities[key]])) };
56
- if (question.type === 'choice') {
57
- if (typeof value.choice !== 'string' || !keys.includes(value.choice)) throw backendError('invalid-response', 'Invalid response choice');
58
- return { ...common, choice: value.choice };
59
- }
60
- if (typeof value.score !== 'number' || !Number.isFinite(value.score) || value.score < 0 || value.score > keys.length - 1) throw backendError('invalid-response', 'Invalid response score');
61
- return { ...common, score: value.score };
62
- }
63
-
64
- function validateResponse(value, questions) {
65
- if (!isObject(value) || !isObject(value.answers) || typeof value.model !== 'string') throw backendError('invalid-response', 'Invalid response');
66
- const answers = Object.create(null);
67
- for (const [id, question] of Object.entries(questions)) answers[id] = validateAnswer(value.answers[id], question);
68
- return { model: value.model, answers, usage: isObject(value.usage) ? {
69
- input_tokens: Number.isSafeInteger(value.usage.input_tokens) ? value.usage.input_tokens : 0,
70
- output_tokens: Number.isSafeInteger(value.usage.output_tokens) ? value.usage.output_tokens : 0,
71
- } : undefined };
72
- }
73
-
74
- async function boundedJson(response) {
75
- const reader = response.body?.getReader();
76
- if (!reader) throw backendError('invalid-response', 'Invalid response body');
77
- const chunks = [];
78
- let size = 0;
79
- try {
80
- while (true) {
81
- const { done, value } = await reader.read();
82
- if (done) break;
83
- size += value.byteLength;
84
- if (size > RESPONSE_LIMIT) throw backendError('invalid-response', 'Invalid response size');
85
- chunks.push(value);
86
- }
87
- } finally { await reader.cancel().catch(() => {}); }
88
- try { return JSON.parse(Buffer.concat(chunks).toString('utf8')); } catch { throw backendError('invalid-response', 'Invalid response JSON'); }
89
- }
90
-
91
- export function createBackend({ config, fetcher = globalThis.fetch, timeoutMs = 20_000, retryDelayMs = 200 }) {
92
- if (typeof config !== 'function' || typeof fetcher !== 'function' ||
93
- !Number.isSafeInteger(timeoutMs) || timeoutMs < 1 || !Number.isSafeInteger(retryDelayMs) || retryDelayMs < 0 || retryDelayMs > 5000) {
94
- throw new Error('Invalid backend dependencies');
95
- }
96
- return {
97
- async evaluate(input, { signal } = {}) {
98
- const { state, questions } = validateRequest(input);
99
- const settings = await config();
100
- const key = settings.apiKey?.trim();
101
- if (!key || /[\r\n]/.test(key)) throw backendError('config', 'API key is required');
102
- let url;
103
- try { url = new URL(settings.url); } catch { throw backendError('config', 'Invalid backend URL'); }
104
- if (!['https:', 'http:'].includes(url.protocol) || url.username || url.password || url.search || url.hash) throw backendError('config', 'Invalid backend URL');
105
- if (url.protocol === 'http:' && settings.httpApprovedUrl !== settings.url) throw backendError('config', 'HTTP URL requires confirmation');
106
- const model = settings.model || DEFAULT_MODEL;
107
- if (typeof model !== 'string' || !model.trim() || /[\r\n]/.test(model)) throw backendError('config', 'Invalid model');
108
- const endpoint = new URL(url.href);
109
- endpoint.pathname = `${url.pathname.replace(/\/+$/, '')}/v1/systemone`;
110
- if (endpoint.origin !== url.origin) throw backendError('config', 'Invalid backend endpoint');
111
- const timeout = AbortSignal.timeout(timeoutMs);
112
- const combined = signal ? AbortSignal.any([signal, timeout]) : timeout;
113
- for (let attempt = 0; attempt < 3; attempt++) {
114
- let reply;
115
- try {
116
- reply = await fetcher(endpoint.href, { method: 'POST', redirect: 'error', signal: combined,
117
- headers: { authorization: `Bearer ${key}`, 'content-type': 'application/json' }, body: JSON.stringify({ state, questions, model }) });
118
- } catch { throw backendError('unreachable', 'Backend request failed or timed out'); }
119
- if (reply.ok) return validateResponse(await boundedJson(reply), questions);
120
- await reply.body?.cancel().catch(() => {});
121
- if (![429, 529].includes(reply.status) || attempt === 2) throw backendError('http-error', `Backend HTTP ${reply.status}`);
122
- try { await wait(retryDelayMs * (attempt + 1), undefined, { signal: combined }); }
123
- catch { throw backendError('unreachable', 'Backend request failed or timed out'); }
124
- }
125
- },
126
- };
127
- }