@jsonstudio/llms 0.6.938 → 0.6.1164

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 (131) hide show
  1. package/dist/conversion/hub/operation-table/operation-table-runner.d.ts +18 -0
  2. package/dist/conversion/hub/operation-table/operation-table-runner.js +158 -0
  3. package/dist/conversion/hub/operation-table/semantic-mappers/anthropic-mapper.d.ts +8 -0
  4. package/dist/conversion/hub/operation-table/semantic-mappers/anthropic-mapper.js +303 -0
  5. package/dist/conversion/hub/operation-table/semantic-mappers/chat-mapper.d.ts +8 -0
  6. package/dist/conversion/hub/operation-table/semantic-mappers/chat-mapper.js +413 -0
  7. package/dist/conversion/hub/operation-table/semantic-mappers/gemini-mapper.d.ts +7 -0
  8. package/dist/conversion/hub/operation-table/semantic-mappers/gemini-mapper.js +841 -0
  9. package/dist/conversion/hub/operation-table/semantic-mappers/responses-mapper.d.ts +21 -0
  10. package/dist/conversion/hub/operation-table/semantic-mappers/responses-mapper.js +535 -0
  11. package/dist/conversion/hub/ops/operations.d.ts +19 -0
  12. package/dist/conversion/hub/ops/operations.js +126 -0
  13. package/dist/conversion/hub/pipeline/hub-pipeline.d.ts +9 -0
  14. package/dist/conversion/hub/pipeline/hub-pipeline.js +533 -24
  15. package/dist/conversion/hub/pipeline/stages/req_inbound/req_inbound_stage2_semantic_map/index.js +6 -0
  16. package/dist/conversion/hub/pipeline/stages/req_inbound/req_inbound_stage3_context_capture/index.js +6 -3
  17. package/dist/conversion/hub/pipeline/stages/req_outbound/req_outbound_stage1_semantic_map/index.js +11 -0
  18. package/dist/conversion/hub/policy/policy-engine.js +41 -9
  19. package/dist/conversion/hub/policy/protocol-spec.d.ts +25 -0
  20. package/dist/conversion/hub/policy/protocol-spec.js +73 -23
  21. package/dist/conversion/hub/process/chat-process.js +252 -41
  22. package/dist/conversion/hub/response/provider-response.js +175 -2
  23. package/dist/conversion/hub/response/response-runtime.js +1 -1
  24. package/dist/conversion/hub/semantic-mappers/anthropic-mapper.d.ts +1 -8
  25. package/dist/conversion/hub/semantic-mappers/anthropic-mapper.js +1 -365
  26. package/dist/conversion/hub/semantic-mappers/chat-mapper.d.ts +1 -8
  27. package/dist/conversion/hub/semantic-mappers/chat-mapper.js +1 -436
  28. package/dist/conversion/hub/semantic-mappers/gemini-mapper.d.ts +1 -7
  29. package/dist/conversion/hub/semantic-mappers/gemini-mapper.js +1 -894
  30. package/dist/conversion/hub/semantic-mappers/responses-mapper.d.ts +1 -21
  31. package/dist/conversion/hub/semantic-mappers/responses-mapper.js +1 -593
  32. package/dist/conversion/hub/tool-surface/tool-surface-engine.d.ts +18 -0
  33. package/dist/conversion/hub/tool-surface/tool-surface-engine.js +571 -0
  34. package/dist/conversion/responses/responses-openai-bridge.js +14 -2
  35. package/dist/conversion/shared/bridge-message-utils.js +2 -8
  36. package/dist/conversion/shared/bridge-policies.js +5 -105
  37. package/dist/conversion/shared/gemini-tool-utils.js +121 -4
  38. package/dist/conversion/shared/protocol-field-allowlists.d.ts +7 -0
  39. package/dist/conversion/shared/protocol-field-allowlists.js +145 -0
  40. package/dist/conversion/shared/reasoning-tool-normalizer.js +4 -2
  41. package/dist/conversion/shared/snapshot-hooks.js +166 -3
  42. package/dist/conversion/shared/text-markup-normalizer.d.ts +2 -0
  43. package/dist/conversion/shared/text-markup-normalizer.js +345 -9
  44. package/dist/conversion/shared/thought-signature-validator.d.ts +52 -0
  45. package/dist/conversion/shared/thought-signature-validator.js +170 -0
  46. package/dist/conversion/shared/tool-argument-repairer.d.ts +39 -0
  47. package/dist/conversion/shared/tool-argument-repairer.js +56 -0
  48. package/dist/conversion/shared/tool-call-id-manager.d.ts +113 -0
  49. package/dist/conversion/shared/tool-call-id-manager.js +231 -0
  50. package/dist/conversion/shared/tool-canonicalizer.js +2 -11
  51. package/dist/router/virtual-router/bootstrap.js +54 -5
  52. package/dist/router/virtual-router/engine-selection.js +132 -42
  53. package/dist/router/virtual-router/engine.d.ts +3 -0
  54. package/dist/router/virtual-router/engine.js +142 -33
  55. package/dist/router/virtual-router/health-weighted.d.ts +25 -0
  56. package/dist/router/virtual-router/health-weighted.js +63 -0
  57. package/dist/router/virtual-router/load-balancer.d.ts +2 -0
  58. package/dist/router/virtual-router/load-balancer.js +45 -16
  59. package/dist/router/virtual-router/routing-instructions.js +17 -1
  60. package/dist/router/virtual-router/sticky-session-store.js +136 -24
  61. package/dist/router/virtual-router/stop-message-file-resolver.d.ts +1 -0
  62. package/dist/router/virtual-router/stop-message-file-resolver.js +74 -0
  63. package/dist/router/virtual-router/stop-message-state-sync.d.ts +15 -0
  64. package/dist/router/virtual-router/stop-message-state-sync.js +57 -0
  65. package/dist/router/virtual-router/types.d.ts +70 -0
  66. package/dist/servertool/clock/config.d.ts +7 -0
  67. package/dist/servertool/clock/config.js +27 -0
  68. package/dist/servertool/clock/daemon.d.ts +3 -0
  69. package/dist/servertool/clock/daemon.js +79 -0
  70. package/dist/servertool/clock/io.d.ts +2 -0
  71. package/dist/servertool/clock/io.js +13 -0
  72. package/dist/servertool/clock/paths.d.ts +4 -0
  73. package/dist/servertool/clock/paths.js +25 -0
  74. package/dist/servertool/clock/session-store.d.ts +3 -0
  75. package/dist/servertool/clock/session-store.js +56 -0
  76. package/dist/servertool/clock/state.d.ts +5 -0
  77. package/dist/servertool/clock/state.js +62 -0
  78. package/dist/servertool/clock/task-store.d.ts +5 -0
  79. package/dist/servertool/clock/task-store.js +4 -0
  80. package/dist/servertool/clock/tasks.d.ts +17 -0
  81. package/dist/servertool/clock/tasks.js +221 -0
  82. package/dist/servertool/clock/types.d.ts +36 -0
  83. package/dist/servertool/clock/types.js +1 -0
  84. package/dist/servertool/engine.d.ts +2 -0
  85. package/dist/servertool/engine.js +164 -8
  86. package/dist/servertool/followup-shadow.d.ts +16 -0
  87. package/dist/servertool/followup-shadow.js +145 -0
  88. package/dist/servertool/handlers/apply-patch-guard.js +1 -265
  89. package/dist/servertool/handlers/clock-auto.d.ts +1 -0
  90. package/dist/servertool/handlers/clock-auto.js +160 -0
  91. package/dist/servertool/handlers/clock.d.ts +1 -0
  92. package/dist/servertool/handlers/clock.js +197 -0
  93. package/dist/servertool/handlers/exec-command-guard.js +7 -555
  94. package/dist/servertool/handlers/followup-request-builder.d.ts +15 -7
  95. package/dist/servertool/handlers/followup-request-builder.js +248 -28
  96. package/dist/servertool/handlers/gemini-empty-reply-continue.js +62 -169
  97. package/dist/servertool/handlers/iflow-model-error-retry.js +18 -28
  98. package/dist/servertool/handlers/recursive-detection-guard.d.ts +1 -0
  99. package/dist/servertool/handlers/recursive-detection-guard.js +333 -0
  100. package/dist/servertool/handlers/stop-message-auto.js +47 -175
  101. package/dist/servertool/handlers/vision.d.ts +7 -1
  102. package/dist/servertool/handlers/vision.js +61 -117
  103. package/dist/servertool/handlers/web-search.d.ts +7 -1
  104. package/dist/servertool/handlers/web-search.js +122 -105
  105. package/dist/servertool/reenter-backend.d.ts +23 -0
  106. package/dist/servertool/reenter-backend.js +18 -0
  107. package/dist/servertool/server-side-tools.d.ts +3 -2
  108. package/dist/servertool/server-side-tools.js +64 -10
  109. package/dist/servertool/types.d.ts +92 -3
  110. package/dist/sse/json-to-sse/event-generators/responses.js +3 -21
  111. package/dist/sse/shared/serializers/responses-event-serializer.d.ts +8 -0
  112. package/dist/sse/shared/serializers/responses-event-serializer.js +19 -0
  113. package/dist/sse/shared/writer.js +24 -7
  114. package/dist/tools/apply-patch/execution-capturer.js +3 -1
  115. package/dist/tools/apply-patch/json/parse-loose.d.ts +3 -0
  116. package/dist/tools/apply-patch/json/parse-loose.js +139 -0
  117. package/dist/tools/apply-patch/patch-text/context-diff.d.ts +1 -0
  118. package/dist/tools/apply-patch/patch-text/context-diff.js +173 -0
  119. package/dist/tools/apply-patch/patch-text/git-diff.d.ts +1 -0
  120. package/dist/tools/apply-patch/patch-text/git-diff.js +138 -0
  121. package/dist/tools/apply-patch/patch-text/looks-like-patch.d.ts +1 -0
  122. package/dist/tools/apply-patch/patch-text/looks-like-patch.js +13 -0
  123. package/dist/tools/apply-patch/patch-text/normalize.d.ts +3 -0
  124. package/dist/tools/apply-patch/patch-text/normalize.js +262 -0
  125. package/dist/tools/apply-patch/structured/coercion.d.ts +3 -0
  126. package/dist/tools/apply-patch/structured/coercion.js +82 -0
  127. package/dist/tools/apply-patch/validation/shared.d.ts +3 -0
  128. package/dist/tools/apply-patch/validation/shared.js +6 -0
  129. package/dist/tools/apply-patch/validator.d.ts +2 -2
  130. package/dist/tools/apply-patch/validator.js +6 -556
  131. package/package.json +1 -1
@@ -3,6 +3,7 @@ export interface LoadBalancingOptions {
3
3
  routeName: string;
4
4
  candidates: string[];
5
5
  stickyKey?: string;
6
+ weights?: Record<string, number>;
6
7
  availabilityCheck: (providerKey: string) => boolean;
7
8
  }
8
9
  export declare class RouteLoadBalancer {
@@ -10,6 +11,7 @@ export declare class RouteLoadBalancer {
10
11
  private readonly states;
11
12
  constructor(policy?: LoadBalancingPolicy);
12
13
  updatePolicy(policy?: LoadBalancingPolicy): void;
14
+ getPolicy(): LoadBalancingPolicy;
13
15
  select(options: LoadBalancingOptions, strategyOverride?: LoadBalancingPolicy['strategy']): string | null;
14
16
  private selectRoundRobin;
15
17
  private selectWeighted;
@@ -9,6 +9,9 @@ export class RouteLoadBalancer {
9
9
  this.policy = policy;
10
10
  }
11
11
  }
12
+ getPolicy() {
13
+ return this.policy;
14
+ }
12
15
  select(options, strategyOverride) {
13
16
  const available = options.candidates.filter((candidate) => options.availabilityCheck(candidate));
14
17
  if (available.length === 0) {
@@ -17,10 +20,16 @@ export class RouteLoadBalancer {
17
20
  const strategy = strategyOverride ?? this.policy.strategy;
18
21
  switch (strategy) {
19
22
  case 'sticky':
20
- return this.selectSticky(options.routeName, available, options.stickyKey);
23
+ return this.selectSticky(options.routeName, available, options.stickyKey, options.weights ?? this.policy.weights);
21
24
  case 'weighted':
22
- return this.selectWeighted(available);
25
+ return this.selectWeighted(options.routeName, available, options.weights ?? this.policy.weights);
23
26
  default:
27
+ if (options.weights) {
28
+ const distinct = new Set(available.map((candidate) => Math.max(1, options.weights?.[candidate] ?? 1)));
29
+ if (distinct.size > 1) {
30
+ return this.selectWeighted(options.routeName, available, options.weights);
31
+ }
32
+ }
24
33
  return this.selectRoundRobin(options.routeName, available);
25
34
  }
26
35
  }
@@ -30,23 +39,41 @@ export class RouteLoadBalancer {
30
39
  state.pointer = (state.pointer + 1) % candidates.length;
31
40
  return choice;
32
41
  }
33
- selectWeighted(candidates) {
34
- if (!this.policy.weights) {
35
- return candidates[0];
42
+ selectWeighted(routeName, candidates, weights) {
43
+ // Deterministic smooth weighted round-robin (no randomness) so routing behavior is testable and stable.
44
+ // Each candidate with a positive weight is guaranteed to be selected eventually.
45
+ const state = this.getState(routeName);
46
+ const current = state.weighted.currentWeights;
47
+ const candidateSet = new Set(candidates);
48
+ for (const existing of Array.from(current.keys())) {
49
+ if (!candidateSet.has(existing)) {
50
+ current.delete(existing);
51
+ }
52
+ }
53
+ for (const key of candidates) {
54
+ if (!current.has(key)) {
55
+ current.set(key, 0);
56
+ }
36
57
  }
37
- const weights = candidates.map((candidate) => Math.max(1, this.policy.weights?.[candidate] ?? 1));
38
- const total = weights.reduce((sum, weight) => sum + weight, 0);
39
- const threshold = Math.random() * total;
40
- let running = 0;
58
+ const candidateWeights = candidates.map((candidate) => Math.max(1, weights?.[candidate] ?? 1));
59
+ const totalWeight = candidateWeights.reduce((sum, w) => sum + w, 0);
60
+ let bestIndex = 0;
61
+ let bestScore = Number.NEGATIVE_INFINITY;
41
62
  for (let i = 0; i < candidates.length; i += 1) {
42
- running += weights[i];
43
- if (running >= threshold) {
44
- return candidates[i];
63
+ const key = candidates[i];
64
+ const w = candidateWeights[i];
65
+ const next = (current.get(key) ?? 0) + w;
66
+ current.set(key, next);
67
+ if (next > bestScore) {
68
+ bestScore = next;
69
+ bestIndex = i;
45
70
  }
46
71
  }
47
- return candidates[candidates.length - 1];
72
+ const selectedKey = candidates[bestIndex];
73
+ current.set(selectedKey, (current.get(selectedKey) ?? 0) - totalWeight);
74
+ return selectedKey;
48
75
  }
49
- selectSticky(routeName, candidates, stickyKey) {
76
+ selectSticky(routeName, candidates, stickyKey, weights) {
50
77
  if (!stickyKey) {
51
78
  return this.selectRoundRobin(routeName, candidates);
52
79
  }
@@ -55,13 +82,15 @@ export class RouteLoadBalancer {
55
82
  if (pinned && candidates.includes(pinned)) {
56
83
  return pinned;
57
84
  }
58
- const choice = this.selectRoundRobin(routeName, candidates);
85
+ const choice = weights && Object.keys(weights).length > 0
86
+ ? this.selectWeighted(`${routeName}:sticky`, candidates, weights)
87
+ : this.selectRoundRobin(routeName, candidates);
59
88
  state.stickyMap.set(stickyKey, choice);
60
89
  return choice;
61
90
  }
62
91
  getState(routeName) {
63
92
  if (!this.states.has(routeName)) {
64
- this.states.set(routeName, { pointer: 0, stickyMap: new Map() });
93
+ this.states.set(routeName, { pointer: 0, stickyMap: new Map(), weighted: { currentWeights: new Map() } });
65
94
  }
66
95
  return this.states.get(routeName);
67
96
  }
@@ -1,15 +1,21 @@
1
1
  import { extractMessageText } from './message-utils.js';
2
+ import { resolveStopMessageText } from './stop-message-file-resolver.js';
2
3
  export function parseRoutingInstructions(messages) {
3
4
  const instructions = [];
4
5
  // 从最新一条携带路由指令标记(<** ... **>)的 user 消息中解析指令,
5
6
  // 而不是简单地取"最后一条 user 消息"。这样可以在服务重启后,通过完整
6
7
  // 会话历史恢复 sticky/黑名单状态,同时保持"最后一次指令生效"的语义。
7
8
  let sanitized = null;
9
+ let sanitizedIndex = -1;
10
+ let lastUserIndex = -1;
8
11
  for (let idx = messages.length - 1; idx >= 0; idx -= 1) {
9
12
  const message = messages[idx];
10
13
  if (!message || message.role !== 'user') {
11
14
  continue;
12
15
  }
16
+ if (lastUserIndex < 0) {
17
+ lastUserIndex = idx;
18
+ }
13
19
  const content = extractMessageText(message);
14
20
  if (!content) {
15
21
  continue;
@@ -22,6 +28,7 @@ export function parseRoutingInstructions(messages) {
22
28
  continue;
23
29
  }
24
30
  sanitized = candidate;
31
+ sanitizedIndex = idx;
25
32
  break;
26
33
  }
27
34
  if (!sanitized) {
@@ -38,6 +45,15 @@ export function parseRoutingInstructions(messages) {
38
45
  for (const segment of segments) {
39
46
  const parsed = parseSingleInstruction(segment);
40
47
  if (parsed) {
48
+ // stopMessage is a "command" and must only be set/cleared from the *latest* user message.
49
+ // Otherwise, clients that resend full history (including a past "<**stopMessage:...**>" message)
50
+ // would keep re-applying stopMessage after it has been consumed/cleared.
51
+ if ((parsed.type === 'stopMessageSet' || parsed.type === 'stopMessageClear') &&
52
+ lastUserIndex >= 0 &&
53
+ sanitizedIndex >= 0 &&
54
+ sanitizedIndex !== lastUserIndex) {
55
+ continue;
56
+ }
41
57
  instructions.push(parsed);
42
58
  }
43
59
  }
@@ -176,7 +192,7 @@ function parseSingleInstruction(instruction) {
176
192
  }
177
193
  return {
178
194
  type: 'stopMessageSet',
179
- stopMessageText: text,
195
+ stopMessageText: resolveStopMessageText(text),
180
196
  stopMessageMaxRepeats: maxRepeats
181
197
  };
182
198
  }
@@ -2,6 +2,7 @@ import * as fs from 'node:fs';
2
2
  import * as path from 'node:path';
3
3
  import os from 'node:os';
4
4
  import { serializeRoutingInstructionState, deserializeRoutingInstructionState } from './routing-instructions.js';
5
+ const pendingWrites = new Map();
5
6
  function isPersistentKey(key) {
6
7
  if (!key)
7
8
  return false;
@@ -54,7 +55,26 @@ export function loadRoutingInstructionStateSync(key) {
54
55
  if (!raw) {
55
56
  return null;
56
57
  }
57
- const parsed = JSON.parse(raw);
58
+ let parsed;
59
+ try {
60
+ parsed = JSON.parse(raw);
61
+ }
62
+ catch {
63
+ const recovered = recoverPersistedJson(raw);
64
+ if (!recovered) {
65
+ return null;
66
+ }
67
+ parsed = recovered;
68
+ try {
69
+ const payload = parsed && typeof parsed.version === 'number'
70
+ ? parsed
71
+ : { version: 1, state: parsed };
72
+ atomicWriteFileSync(filepath, JSON.stringify(payload));
73
+ }
74
+ catch {
75
+ // ignore rewrite failures
76
+ }
77
+ }
58
78
  const payload = parsed && typeof parsed.version === 'number'
59
79
  ? parsed.state
60
80
  : parsed;
@@ -79,34 +99,34 @@ export function saveRoutingInstructionStateAsync(key, state) {
79
99
  const filepath = path.join(dir, filename);
80
100
  // 空状态意味着清除持久化文件
81
101
  if (!state) {
82
- try {
83
- fs.unlink(filepath, () => {
84
- // ignore errors (e.g. ENOENT)
85
- });
86
- }
87
- catch {
88
- // ignore sync unlink failures
89
- }
102
+ scheduleWrite(filepath, async () => {
103
+ try {
104
+ await fs.promises.unlink(filepath);
105
+ }
106
+ catch {
107
+ // ignore unlink errors (e.g. ENOENT)
108
+ }
109
+ });
90
110
  return;
91
111
  }
92
112
  const payload = {
93
113
  version: 1,
94
114
  state: serializeRoutingInstructionState(state)
95
115
  };
96
- try {
97
- fs.mkdirSync(dir, { recursive: true });
98
- }
99
- catch {
100
- // ignore mkdir errors; write below will fail silently
101
- }
102
- try {
103
- fs.writeFile(filepath, JSON.stringify(payload), { encoding: 'utf8' }, () => {
104
- // ignore async write errors
105
- });
106
- }
107
- catch {
108
- // ignore sync write failures
109
- }
116
+ scheduleWrite(filepath, async () => {
117
+ try {
118
+ await fs.promises.mkdir(dir, { recursive: true });
119
+ }
120
+ catch {
121
+ // ignore mkdir errors; write below will fail silently
122
+ }
123
+ try {
124
+ await atomicWriteFile(filepath, JSON.stringify(payload));
125
+ }
126
+ catch {
127
+ // ignore async write failures
128
+ }
129
+ });
110
130
  }
111
131
  export function saveRoutingInstructionStateSync(key, state) {
112
132
  if (!isPersistentKey(key)) {
@@ -138,9 +158,101 @@ export function saveRoutingInstructionStateSync(key, state) {
138
158
  // ignore mkdir errors
139
159
  }
140
160
  try {
141
- fs.writeFileSync(filepath, JSON.stringify(payload), { encoding: 'utf8' });
161
+ atomicWriteFileSync(filepath, JSON.stringify(payload));
142
162
  }
143
163
  catch {
144
164
  // ignore sync write failures
145
165
  }
146
166
  }
167
+ function scheduleWrite(filepath, task) {
168
+ const previous = pendingWrites.get(filepath) ?? Promise.resolve();
169
+ const next = previous
170
+ .then(task)
171
+ .catch(() => {
172
+ // swallow errors
173
+ })
174
+ .finally(() => {
175
+ if (pendingWrites.get(filepath) === next) {
176
+ pendingWrites.delete(filepath);
177
+ }
178
+ });
179
+ pendingWrites.set(filepath, next);
180
+ }
181
+ async function atomicWriteFile(filepath, content) {
182
+ const tmp = `${filepath}.tmp-${process.pid}-${Date.now()}-${Math.random().toString(16).slice(2)}`;
183
+ try {
184
+ await fs.promises.writeFile(tmp, content, { encoding: 'utf8' });
185
+ try {
186
+ await fs.promises.rename(tmp, filepath);
187
+ }
188
+ catch {
189
+ try {
190
+ await fs.promises.unlink(filepath);
191
+ }
192
+ catch {
193
+ // ignore unlink failures
194
+ }
195
+ await fs.promises.rename(tmp, filepath);
196
+ }
197
+ }
198
+ finally {
199
+ try {
200
+ await fs.promises.unlink(tmp);
201
+ }
202
+ catch {
203
+ // ignore tmp cleanup failures
204
+ }
205
+ }
206
+ }
207
+ function atomicWriteFileSync(filepath, content) {
208
+ const tmp = `${filepath}.tmp-${process.pid}-${Date.now()}-${Math.random().toString(16).slice(2)}`;
209
+ try {
210
+ fs.writeFileSync(tmp, content, { encoding: 'utf8' });
211
+ try {
212
+ fs.renameSync(tmp, filepath);
213
+ }
214
+ catch {
215
+ try {
216
+ fs.unlinkSync(filepath);
217
+ }
218
+ catch {
219
+ // ignore unlink failures
220
+ }
221
+ fs.renameSync(tmp, filepath);
222
+ }
223
+ }
224
+ finally {
225
+ try {
226
+ fs.unlinkSync(tmp);
227
+ }
228
+ catch {
229
+ // ignore tmp cleanup failures
230
+ }
231
+ }
232
+ }
233
+ function recoverPersistedJson(raw) {
234
+ if (typeof raw !== 'string') {
235
+ return null;
236
+ }
237
+ const text = raw.trim();
238
+ if (!text.startsWith('{')) {
239
+ return null;
240
+ }
241
+ const maxScan = Math.min(text.length, 256 * 1024);
242
+ for (let i = maxScan - 1; i >= 1; i -= 1) {
243
+ if (text[i] !== '}') {
244
+ continue;
245
+ }
246
+ const candidate = text.slice(0, i + 1);
247
+ try {
248
+ const parsed = JSON.parse(candidate);
249
+ if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
250
+ return parsed;
251
+ }
252
+ }
253
+ catch {
254
+ // keep scanning
255
+ }
256
+ }
257
+ return null;
258
+ }
@@ -0,0 +1 @@
1
+ export declare function resolveStopMessageText(raw: string): string;
@@ -0,0 +1,74 @@
1
+ import * as fs from 'node:fs';
2
+ import * as path from 'node:path';
3
+ import os from 'node:os';
4
+ const cache = new Map();
5
+ function resolveRoutecodexUserDir() {
6
+ const override = process.env.ROUTECODEX_USER_DIR;
7
+ if (override && override.trim()) {
8
+ return override.trim();
9
+ }
10
+ const home = os.homedir();
11
+ if (!home) {
12
+ throw new Error('stopMessage file://: cannot resolve homedir');
13
+ }
14
+ return path.join(home, '.routecodex');
15
+ }
16
+ function resolveStopMessageFilePath(raw) {
17
+ let text = raw.trim();
18
+ if (!text)
19
+ return null;
20
+ if (text.startsWith('<') && text.endsWith('>') && text.length >= 3) {
21
+ text = text.slice(1, -1).trim();
22
+ }
23
+ if (!/^file:\/\//i.test(text)) {
24
+ return null;
25
+ }
26
+ const relRaw = text.slice('file://'.length).trim();
27
+ if (!relRaw) {
28
+ throw new Error('stopMessage file://: missing relative path');
29
+ }
30
+ if (relRaw.startsWith('/') || relRaw.startsWith('\\') || /^[a-zA-Z]:[\\/]/.test(relRaw)) {
31
+ throw new Error('stopMessage file://: only supports paths relative to ~/.routecodex');
32
+ }
33
+ const normalizedRel = path.posix.normalize(relRaw.replace(/\\/g, '/'));
34
+ if (!normalizedRel || normalizedRel === '.' || normalizedRel === '..' || normalizedRel.startsWith('../')) {
35
+ throw new Error('stopMessage file://: invalid relative path');
36
+ }
37
+ const base = path.resolve(resolveRoutecodexUserDir());
38
+ const abs = path.resolve(base, normalizedRel);
39
+ if (abs !== base && !abs.startsWith(`${base}${path.sep}`)) {
40
+ throw new Error('stopMessage file://: path escapes ~/.routecodex');
41
+ }
42
+ return abs;
43
+ }
44
+ export function resolveStopMessageText(raw) {
45
+ const abs = resolveStopMessageFilePath(raw);
46
+ if (!abs) {
47
+ return raw;
48
+ }
49
+ let stat;
50
+ try {
51
+ stat = fs.statSync(abs);
52
+ }
53
+ catch (err) {
54
+ const message = err && typeof err.message === 'string' ? err.message : String(err || 'unknown error');
55
+ throw new Error(`stopMessage file://: cannot stat ${abs}: ${message}`);
56
+ }
57
+ if (!stat.isFile()) {
58
+ throw new Error(`stopMessage file://: not a file: ${abs}`);
59
+ }
60
+ const existing = cache.get(abs);
61
+ if (existing && existing.mtimeMs === stat.mtimeMs && existing.size === stat.size) {
62
+ return existing.content;
63
+ }
64
+ let content;
65
+ try {
66
+ content = fs.readFileSync(abs, 'utf8');
67
+ }
68
+ catch (err) {
69
+ const message = err && typeof err.message === 'string' ? err.message : String(err || 'unknown error');
70
+ throw new Error(`stopMessage file://: cannot read ${abs}: ${message}`);
71
+ }
72
+ cache.set(abs, { mtimeMs: stat.mtimeMs, size: stat.size, content });
73
+ return content;
74
+ }
@@ -0,0 +1,15 @@
1
+ import type { RoutingInstructionState } from './routing-instructions.js';
2
+ type StopMessageSubset = Pick<RoutingInstructionState, 'stopMessageSource' | 'stopMessageText' | 'stopMessageMaxRepeats' | 'stopMessageUsed' | 'stopMessageUpdatedAt' | 'stopMessageLastUsedAt'>;
3
+ /**
4
+ * Decide whether we should overwrite in-memory stopMessage fields with persisted ones.
5
+ *
6
+ * Key invariant:
7
+ * - In-memory state may be ahead of disk because persistence is async (tmp+rename).
8
+ * - Persisted state must still be able to update usage counters (stop_message_auto).
9
+ *
10
+ * Strategy:
11
+ * - If existing has a newer stopMessageUpdatedAt than persisted → keep existing config.
12
+ * - Otherwise → adopt persisted fully.
13
+ */
14
+ export declare function mergeStopMessageFromPersisted(existing: StopMessageSubset, persisted: StopMessageSubset | null): StopMessageSubset;
15
+ export {};
@@ -0,0 +1,57 @@
1
+ function isFiniteNumber(value) {
2
+ return typeof value === 'number' && Number.isFinite(value);
3
+ }
4
+ function updatedAtOf(state) {
5
+ if (!state)
6
+ return null;
7
+ return isFiniteNumber(state.stopMessageUpdatedAt) ? state.stopMessageUpdatedAt : null;
8
+ }
9
+ function lastUsedAtOf(state) {
10
+ if (!state)
11
+ return null;
12
+ return isFiniteNumber(state.stopMessageLastUsedAt) ? state.stopMessageLastUsedAt : null;
13
+ }
14
+ /**
15
+ * Decide whether we should overwrite in-memory stopMessage fields with persisted ones.
16
+ *
17
+ * Key invariant:
18
+ * - In-memory state may be ahead of disk because persistence is async (tmp+rename).
19
+ * - Persisted state must still be able to update usage counters (stop_message_auto).
20
+ *
21
+ * Strategy:
22
+ * - If existing has a newer stopMessageUpdatedAt than persisted → keep existing config.
23
+ * - Otherwise → adopt persisted fully.
24
+ */
25
+ export function mergeStopMessageFromPersisted(existing, persisted) {
26
+ if (!persisted) {
27
+ return { ...existing };
28
+ }
29
+ const existingUpdatedAt = updatedAtOf(existing);
30
+ const persistedUpdatedAt = updatedAtOf(persisted);
31
+ const existingIsNewer = existingUpdatedAt !== null && (persistedUpdatedAt === null || persistedUpdatedAt < existingUpdatedAt);
32
+ if (!existingIsNewer) {
33
+ return {
34
+ ...existing,
35
+ stopMessageSource: persisted.stopMessageSource,
36
+ stopMessageText: persisted.stopMessageText,
37
+ stopMessageMaxRepeats: persisted.stopMessageMaxRepeats,
38
+ stopMessageUsed: persisted.stopMessageUsed,
39
+ stopMessageUpdatedAt: persisted.stopMessageUpdatedAt,
40
+ stopMessageLastUsedAt: persisted.stopMessageLastUsedAt
41
+ };
42
+ }
43
+ // Keep existing config, but still allow persisted usage counters to move forward if they are newer.
44
+ const existingLastUsedAt = lastUsedAtOf(existing);
45
+ const persistedLastUsedAt = lastUsedAtOf(persisted);
46
+ const countersAreNewer = persistedLastUsedAt !== null &&
47
+ (existingLastUsedAt === null || persistedLastUsedAt > existingLastUsedAt);
48
+ return {
49
+ ...existing,
50
+ ...(countersAreNewer
51
+ ? {
52
+ stopMessageUsed: persisted.stopMessageUsed,
53
+ stopMessageLastUsedAt: persisted.stopMessageLastUsedAt
54
+ }
55
+ : {})
56
+ };
57
+ }
@@ -100,6 +100,41 @@ export interface VirtualRouterClassifierConfig {
100
100
  export interface LoadBalancingPolicy {
101
101
  strategy: 'round-robin' | 'weighted' | 'sticky';
102
102
  weights?: Record<string, number>;
103
+ /**
104
+ * AWRR: health-weighted selection.
105
+ * - Deterministic (no randomness)
106
+ * - Penalizes recently failing keys but never to zero
107
+ * - Gradually recovers weights as time passes without errors
108
+ */
109
+ healthWeighted?: HealthWeightedLoadBalancingConfig;
110
+ }
111
+ export interface HealthWeightedLoadBalancingConfig {
112
+ /**
113
+ * When false, health-weighted logic is disabled and the engine uses legacy behavior.
114
+ * When true/undefined, the engine uses health-weighted behavior if quotaView provides error metadata.
115
+ */
116
+ enabled?: boolean;
117
+ /**
118
+ * Weight resolution. Higher values increase granularity but not semantics.
119
+ */
120
+ baseWeight?: number;
121
+ /**
122
+ * Lower bound for the health multiplier (0 < minMultiplier <= 1).
123
+ * Example: 0.5 means a key's share won't be penalized below ~50% baseline within the same pool.
124
+ */
125
+ minMultiplier?: number;
126
+ /**
127
+ * Penalty slope. Larger beta penalizes errors more aggressively.
128
+ */
129
+ beta?: number;
130
+ /**
131
+ * Half-life for time-based recovery after the last error.
132
+ */
133
+ halfLifeMs?: number;
134
+ /**
135
+ * When true, a router-level retry attempt (excludedProviderKeys non-empty) prefers the healthiest candidate first.
136
+ */
137
+ recoverToBestOnRetry?: boolean;
103
138
  }
104
139
  export interface ProviderHealthConfig {
105
140
  failureThreshold: number;
@@ -136,6 +171,22 @@ export interface VirtualRouterExecCommandGuardConfig {
136
171
  */
137
172
  policyFile?: string;
138
173
  }
174
+ export interface VirtualRouterClockConfig {
175
+ enabled: boolean;
176
+ /**
177
+ * Task retention after dueAt (ms). Tasks older than (dueAt + retentionMs)
178
+ * are eligible for cleanup.
179
+ */
180
+ retentionMs?: number;
181
+ /**
182
+ * "Due window" in ms. A task is considered due when now >= dueAt - dueWindowMs.
183
+ */
184
+ dueWindowMs?: number;
185
+ /**
186
+ * Daemon tick interval (ms). 0 disables background cleanup tick (still cleans on load).
187
+ */
188
+ tickMs?: number;
189
+ }
139
190
  export interface VirtualRouterConfig {
140
191
  routing: RoutingPools;
141
192
  providers: Record<string, ProviderProfile>;
@@ -145,6 +196,7 @@ export interface VirtualRouterConfig {
145
196
  contextRouting?: VirtualRouterContextRoutingConfig;
146
197
  webSearch?: VirtualRouterWebSearchConfig;
147
198
  execCommandGuard?: VirtualRouterExecCommandGuardConfig;
199
+ clock?: VirtualRouterClockConfig;
148
200
  }
149
201
  export interface VirtualRouterContextRoutingConfig {
150
202
  warnRatio: number;
@@ -161,6 +213,7 @@ export interface VirtualRouterBootstrapInput extends Record<string, unknown> {
161
213
  contextRouting?: VirtualRouterContextRoutingConfig;
162
214
  webSearch?: VirtualRouterWebSearchConfig | Record<string, unknown>;
163
215
  execCommandGuard?: VirtualRouterExecCommandGuardConfig | Record<string, unknown>;
216
+ clock?: VirtualRouterClockConfig | Record<string, unknown>;
164
217
  }
165
218
  export type ProviderRuntimeMap = Record<string, ProviderRuntimeProfile>;
166
219
  export interface VirtualRouterBootstrapResult {
@@ -409,6 +462,23 @@ export interface ProviderQuotaViewEntry {
409
462
  inPool: boolean;
410
463
  reason?: string;
411
464
  priorityTier?: number;
465
+ /**
466
+ * Optional soft penalty hint for selection ordering.
467
+ * - 0 / undefined means no penalty
468
+ * - higher means less preferred (e.g. recent transient errors)
469
+ *
470
+ * This does NOT exclude the provider from the pool; exclusion is controlled by
471
+ * inPool/cooldownUntil/blacklistUntil.
472
+ */
473
+ selectionPenalty?: number;
474
+ /**
475
+ * Optional per-providerKey timestamp of the last error. Used for time-decayed recovery.
476
+ */
477
+ lastErrorAtMs?: number | null;
478
+ /**
479
+ * Optional per-providerKey consecutive error count. Resets to 0 on success.
480
+ */
481
+ consecutiveErrorCount?: number;
412
482
  cooldownUntil?: number | null;
413
483
  blacklistUntil?: number | null;
414
484
  }
@@ -0,0 +1,7 @@
1
+ import type { ClockConfigSnapshot } from './types.js';
2
+ export declare const CLOCK_CONFIG_DEFAULTS: {
3
+ readonly retentionMs: number;
4
+ readonly dueWindowMs: 60000;
5
+ readonly tickMs: 60000;
6
+ };
7
+ export declare function normalizeClockConfig(raw: unknown): ClockConfigSnapshot | null;
@@ -0,0 +1,27 @@
1
+ export const CLOCK_CONFIG_DEFAULTS = {
2
+ retentionMs: 20 * 60_000,
3
+ dueWindowMs: 60_000,
4
+ tickMs: 60_000
5
+ };
6
+ export function normalizeClockConfig(raw) {
7
+ if (!raw || typeof raw !== 'object' || Array.isArray(raw)) {
8
+ return null;
9
+ }
10
+ const record = raw;
11
+ const enabled = record.enabled === true ||
12
+ (typeof record.enabled === 'string' && record.enabled.trim().toLowerCase() === 'true') ||
13
+ (typeof record.enabled === 'number' && record.enabled === 1);
14
+ if (!enabled) {
15
+ return null;
16
+ }
17
+ const retentionMs = typeof record.retentionMs === 'number' && Number.isFinite(record.retentionMs) && record.retentionMs >= 0
18
+ ? Math.floor(record.retentionMs)
19
+ : CLOCK_CONFIG_DEFAULTS.retentionMs;
20
+ const dueWindowMs = typeof record.dueWindowMs === 'number' && Number.isFinite(record.dueWindowMs) && record.dueWindowMs >= 0
21
+ ? Math.floor(record.dueWindowMs)
22
+ : CLOCK_CONFIG_DEFAULTS.dueWindowMs;
23
+ const tickMs = typeof record.tickMs === 'number' && Number.isFinite(record.tickMs) && record.tickMs >= 0
24
+ ? Math.floor(record.tickMs)
25
+ : CLOCK_CONFIG_DEFAULTS.tickMs;
26
+ return { enabled: true, retentionMs, dueWindowMs, tickMs };
27
+ }
@@ -0,0 +1,3 @@
1
+ import type { ClockConfigSnapshot } from './types.js';
2
+ export declare function startClockDaemonIfNeeded(config: ClockConfigSnapshot): Promise<void>;
3
+ export declare function stopClockDaemonForTests(): Promise<void>;