@jsonstudio/llms 0.6.954 → 0.6.1172

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 (134) 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 +489 -19
  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_outbound/req_outbound_stage1_semantic_map/index.js +11 -0
  17. package/dist/conversion/hub/policy/policy-engine.js +41 -9
  18. package/dist/conversion/hub/policy/protocol-spec.d.ts +25 -0
  19. package/dist/conversion/hub/policy/protocol-spec.js +73 -23
  20. package/dist/conversion/hub/process/chat-process.js +252 -41
  21. package/dist/conversion/hub/response/provider-response.js +175 -2
  22. package/dist/conversion/hub/response/response-runtime.js +1 -1
  23. package/dist/conversion/hub/semantic-mappers/anthropic-mapper.d.ts +1 -8
  24. package/dist/conversion/hub/semantic-mappers/anthropic-mapper.js +1 -365
  25. package/dist/conversion/hub/semantic-mappers/chat-mapper.d.ts +1 -8
  26. package/dist/conversion/hub/semantic-mappers/chat-mapper.js +1 -467
  27. package/dist/conversion/hub/semantic-mappers/gemini-mapper.d.ts +1 -7
  28. package/dist/conversion/hub/semantic-mappers/gemini-mapper.js +1 -903
  29. package/dist/conversion/hub/semantic-mappers/responses-mapper.d.ts +1 -21
  30. package/dist/conversion/hub/semantic-mappers/responses-mapper.js +1 -593
  31. package/dist/conversion/hub/tool-surface/tool-surface-engine.d.ts +18 -0
  32. package/dist/conversion/hub/tool-surface/tool-surface-engine.js +571 -0
  33. package/dist/conversion/responses/responses-openai-bridge.js +14 -2
  34. package/dist/conversion/shared/bridge-message-utils.js +2 -8
  35. package/dist/conversion/shared/bridge-policies.js +5 -105
  36. package/dist/conversion/shared/gemini-tool-utils.js +89 -15
  37. package/dist/conversion/shared/protocol-field-allowlists.d.ts +7 -0
  38. package/dist/conversion/shared/protocol-field-allowlists.js +145 -0
  39. package/dist/conversion/shared/reasoning-tool-normalizer.js +4 -2
  40. package/dist/conversion/shared/snapshot-hooks.js +166 -3
  41. package/dist/conversion/shared/text-markup-normalizer.d.ts +2 -0
  42. package/dist/conversion/shared/text-markup-normalizer.js +345 -9
  43. package/dist/conversion/shared/thought-signature-validator.d.ts +52 -0
  44. package/dist/conversion/shared/thought-signature-validator.js +170 -0
  45. package/dist/conversion/shared/tool-argument-repairer.d.ts +39 -0
  46. package/dist/conversion/shared/tool-argument-repairer.js +56 -0
  47. package/dist/conversion/shared/tool-call-id-manager.d.ts +113 -0
  48. package/dist/conversion/shared/tool-call-id-manager.js +231 -0
  49. package/dist/conversion/shared/tool-canonicalizer.js +2 -11
  50. package/dist/router/virtual-router/bootstrap.js +70 -5
  51. package/dist/router/virtual-router/context-advisor.d.ts +4 -0
  52. package/dist/router/virtual-router/context-advisor.js +3 -0
  53. package/dist/router/virtual-router/context-weighted.d.ts +31 -0
  54. package/dist/router/virtual-router/context-weighted.js +54 -0
  55. package/dist/router/virtual-router/engine-selection.js +284 -47
  56. package/dist/router/virtual-router/engine.d.ts +3 -0
  57. package/dist/router/virtual-router/engine.js +142 -33
  58. package/dist/router/virtual-router/health-weighted.d.ts +25 -0
  59. package/dist/router/virtual-router/health-weighted.js +63 -0
  60. package/dist/router/virtual-router/load-balancer.d.ts +2 -0
  61. package/dist/router/virtual-router/load-balancer.js +45 -16
  62. package/dist/router/virtual-router/routing-instructions.js +17 -1
  63. package/dist/router/virtual-router/sticky-session-store.js +136 -24
  64. package/dist/router/virtual-router/stop-message-file-resolver.d.ts +1 -0
  65. package/dist/router/virtual-router/stop-message-file-resolver.js +74 -0
  66. package/dist/router/virtual-router/stop-message-state-sync.d.ts +15 -0
  67. package/dist/router/virtual-router/stop-message-state-sync.js +57 -0
  68. package/dist/router/virtual-router/types.d.ts +98 -0
  69. package/dist/servertool/clock/config.d.ts +7 -0
  70. package/dist/servertool/clock/config.js +27 -0
  71. package/dist/servertool/clock/daemon.d.ts +3 -0
  72. package/dist/servertool/clock/daemon.js +79 -0
  73. package/dist/servertool/clock/io.d.ts +2 -0
  74. package/dist/servertool/clock/io.js +13 -0
  75. package/dist/servertool/clock/paths.d.ts +4 -0
  76. package/dist/servertool/clock/paths.js +25 -0
  77. package/dist/servertool/clock/session-store.d.ts +3 -0
  78. package/dist/servertool/clock/session-store.js +56 -0
  79. package/dist/servertool/clock/state.d.ts +5 -0
  80. package/dist/servertool/clock/state.js +62 -0
  81. package/dist/servertool/clock/task-store.d.ts +5 -0
  82. package/dist/servertool/clock/task-store.js +4 -0
  83. package/dist/servertool/clock/tasks.d.ts +17 -0
  84. package/dist/servertool/clock/tasks.js +221 -0
  85. package/dist/servertool/clock/types.d.ts +36 -0
  86. package/dist/servertool/clock/types.js +1 -0
  87. package/dist/servertool/engine.d.ts +2 -0
  88. package/dist/servertool/engine.js +161 -7
  89. package/dist/servertool/followup-shadow.d.ts +16 -0
  90. package/dist/servertool/followup-shadow.js +145 -0
  91. package/dist/servertool/handlers/apply-patch-guard.js +1 -265
  92. package/dist/servertool/handlers/clock-auto.d.ts +1 -0
  93. package/dist/servertool/handlers/clock-auto.js +160 -0
  94. package/dist/servertool/handlers/clock.d.ts +1 -0
  95. package/dist/servertool/handlers/clock.js +197 -0
  96. package/dist/servertool/handlers/exec-command-guard.js +7 -555
  97. package/dist/servertool/handlers/followup-request-builder.d.ts +15 -7
  98. package/dist/servertool/handlers/followup-request-builder.js +248 -28
  99. package/dist/servertool/handlers/gemini-empty-reply-continue.js +62 -169
  100. package/dist/servertool/handlers/iflow-model-error-retry.js +18 -28
  101. package/dist/servertool/handlers/recursive-detection-guard.d.ts +1 -0
  102. package/dist/servertool/handlers/recursive-detection-guard.js +333 -0
  103. package/dist/servertool/handlers/stop-message-auto.js +47 -175
  104. package/dist/servertool/handlers/vision.d.ts +7 -1
  105. package/dist/servertool/handlers/vision.js +61 -117
  106. package/dist/servertool/handlers/web-search.d.ts +7 -1
  107. package/dist/servertool/handlers/web-search.js +122 -105
  108. package/dist/servertool/reenter-backend.d.ts +23 -0
  109. package/dist/servertool/reenter-backend.js +18 -0
  110. package/dist/servertool/server-side-tools.d.ts +3 -2
  111. package/dist/servertool/server-side-tools.js +64 -10
  112. package/dist/servertool/types.d.ts +92 -3
  113. package/dist/sse/json-to-sse/event-generators/responses.js +3 -21
  114. package/dist/sse/shared/serializers/responses-event-serializer.d.ts +8 -0
  115. package/dist/sse/shared/serializers/responses-event-serializer.js +19 -0
  116. package/dist/sse/shared/writer.js +24 -7
  117. package/dist/tools/apply-patch/execution-capturer.js +3 -1
  118. package/dist/tools/apply-patch/json/parse-loose.d.ts +3 -0
  119. package/dist/tools/apply-patch/json/parse-loose.js +139 -0
  120. package/dist/tools/apply-patch/patch-text/context-diff.d.ts +1 -0
  121. package/dist/tools/apply-patch/patch-text/context-diff.js +173 -0
  122. package/dist/tools/apply-patch/patch-text/git-diff.d.ts +1 -0
  123. package/dist/tools/apply-patch/patch-text/git-diff.js +138 -0
  124. package/dist/tools/apply-patch/patch-text/looks-like-patch.d.ts +1 -0
  125. package/dist/tools/apply-patch/patch-text/looks-like-patch.js +13 -0
  126. package/dist/tools/apply-patch/patch-text/normalize.d.ts +3 -0
  127. package/dist/tools/apply-patch/patch-text/normalize.js +262 -0
  128. package/dist/tools/apply-patch/structured/coercion.d.ts +3 -0
  129. package/dist/tools/apply-patch/structured/coercion.js +82 -0
  130. package/dist/tools/apply-patch/validation/shared.d.ts +3 -0
  131. package/dist/tools/apply-patch/validation/shared.js +6 -0
  132. package/dist/tools/apply-patch/validator.d.ts +2 -2
  133. package/dist/tools/apply-patch/validator.js +6 -556
  134. package/package.json +1 -1
@@ -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,69 @@ 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
+ * Context-aware weighting (best-fit under safe window):
112
+ * - Prefer smaller effective context windows early, to preserve larger windows for later.
113
+ * - Uses ContextAdvisor's warnRatio to compute an "effective safe window" per model.
114
+ * - Caps comparisons by client context (e.g. 200k).
115
+ */
116
+ contextWeighted?: ContextWeightedLoadBalancingConfig;
117
+ }
118
+ export interface HealthWeightedLoadBalancingConfig {
119
+ /**
120
+ * When false, health-weighted logic is disabled and the engine uses legacy behavior.
121
+ * When true/undefined, the engine uses health-weighted behavior if quotaView provides error metadata.
122
+ */
123
+ enabled?: boolean;
124
+ /**
125
+ * Weight resolution. Higher values increase granularity but not semantics.
126
+ */
127
+ baseWeight?: number;
128
+ /**
129
+ * Lower bound for the health multiplier (0 < minMultiplier <= 1).
130
+ * Example: 0.5 means a key's share won't be penalized below ~50% baseline within the same pool.
131
+ */
132
+ minMultiplier?: number;
133
+ /**
134
+ * Penalty slope. Larger beta penalizes errors more aggressively.
135
+ */
136
+ beta?: number;
137
+ /**
138
+ * Half-life for time-based recovery after the last error.
139
+ */
140
+ halfLifeMs?: number;
141
+ /**
142
+ * When true, a router-level retry attempt (excludedProviderKeys non-empty) prefers the healthiest candidate first.
143
+ */
144
+ recoverToBestOnRetry?: boolean;
145
+ }
146
+ export interface ContextWeightedLoadBalancingConfig {
147
+ /**
148
+ * When false, context-weighted logic is disabled.
149
+ * When true/undefined, context-weighted logic applies within the same pool bucket,
150
+ * and only for candidates that are considered "safe" by ContextAdvisor.
151
+ */
152
+ enabled?: boolean;
153
+ /**
154
+ * Client-side maximum usable context (tokens). Models above this are capped.
155
+ * Example: 200000 for Codex/Claude Code style clients.
156
+ */
157
+ clientCapTokens?: number;
158
+ /**
159
+ * Exponent for the compensation ratio. Use 1 for proportional compensation.
160
+ */
161
+ gamma?: number;
162
+ /**
163
+ * Upper bound for the multiplier to avoid extreme skew.
164
+ */
165
+ maxMultiplier?: number;
103
166
  }
104
167
  export interface ProviderHealthConfig {
105
168
  failureThreshold: number;
@@ -136,6 +199,22 @@ export interface VirtualRouterExecCommandGuardConfig {
136
199
  */
137
200
  policyFile?: string;
138
201
  }
202
+ export interface VirtualRouterClockConfig {
203
+ enabled: boolean;
204
+ /**
205
+ * Task retention after dueAt (ms). Tasks older than (dueAt + retentionMs)
206
+ * are eligible for cleanup.
207
+ */
208
+ retentionMs?: number;
209
+ /**
210
+ * "Due window" in ms. A task is considered due when now >= dueAt - dueWindowMs.
211
+ */
212
+ dueWindowMs?: number;
213
+ /**
214
+ * Daemon tick interval (ms). 0 disables background cleanup tick (still cleans on load).
215
+ */
216
+ tickMs?: number;
217
+ }
139
218
  export interface VirtualRouterConfig {
140
219
  routing: RoutingPools;
141
220
  providers: Record<string, ProviderProfile>;
@@ -145,6 +224,7 @@ export interface VirtualRouterConfig {
145
224
  contextRouting?: VirtualRouterContextRoutingConfig;
146
225
  webSearch?: VirtualRouterWebSearchConfig;
147
226
  execCommandGuard?: VirtualRouterExecCommandGuardConfig;
227
+ clock?: VirtualRouterClockConfig;
148
228
  }
149
229
  export interface VirtualRouterContextRoutingConfig {
150
230
  warnRatio: number;
@@ -161,6 +241,7 @@ export interface VirtualRouterBootstrapInput extends Record<string, unknown> {
161
241
  contextRouting?: VirtualRouterContextRoutingConfig;
162
242
  webSearch?: VirtualRouterWebSearchConfig | Record<string, unknown>;
163
243
  execCommandGuard?: VirtualRouterExecCommandGuardConfig | Record<string, unknown>;
244
+ clock?: VirtualRouterClockConfig | Record<string, unknown>;
164
245
  }
165
246
  export type ProviderRuntimeMap = Record<string, ProviderRuntimeProfile>;
166
247
  export interface VirtualRouterBootstrapResult {
@@ -409,6 +490,23 @@ export interface ProviderQuotaViewEntry {
409
490
  inPool: boolean;
410
491
  reason?: string;
411
492
  priorityTier?: number;
493
+ /**
494
+ * Optional soft penalty hint for selection ordering.
495
+ * - 0 / undefined means no penalty
496
+ * - higher means less preferred (e.g. recent transient errors)
497
+ *
498
+ * This does NOT exclude the provider from the pool; exclusion is controlled by
499
+ * inPool/cooldownUntil/blacklistUntil.
500
+ */
501
+ selectionPenalty?: number;
502
+ /**
503
+ * Optional per-providerKey timestamp of the last error. Used for time-decayed recovery.
504
+ */
505
+ lastErrorAtMs?: number | null;
506
+ /**
507
+ * Optional per-providerKey consecutive error count. Resets to 0 on success.
508
+ */
509
+ consecutiveErrorCount?: number;
412
510
  cooldownUntil?: number | null;
413
511
  blacklistUntil?: number | null;
414
512
  }
@@ -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>;
@@ -0,0 +1,79 @@
1
+ import fs from 'node:fs/promises';
2
+ import path from 'node:path';
3
+ import { readSessionDirEnv, resolveClockDir } from './paths.js';
4
+ import { cleanExpiredTasks, coerceState, nowMs } from './state.js';
5
+ import { readJsonFile, writeJsonFileAtomic } from './io.js';
6
+ let daemonStarted = false;
7
+ let daemonTimer;
8
+ let daemonConfig;
9
+ export async function startClockDaemonIfNeeded(config) {
10
+ if (daemonStarted) {
11
+ return;
12
+ }
13
+ const sessionDir = readSessionDirEnv();
14
+ if (!sessionDir) {
15
+ return;
16
+ }
17
+ daemonStarted = true;
18
+ daemonConfig = config;
19
+ const tickOnce = async () => {
20
+ const effective = daemonConfig;
21
+ if (!effective)
22
+ return;
23
+ const base = readSessionDirEnv();
24
+ if (!base)
25
+ return;
26
+ const dir = resolveClockDir(base);
27
+ try {
28
+ const entries = await fs.readdir(dir, { withFileTypes: true });
29
+ const at = nowMs();
30
+ for (const entry of entries) {
31
+ if (!entry.isFile())
32
+ continue;
33
+ if (!entry.name.endsWith('.json'))
34
+ continue;
35
+ const filePath = path.join(dir, entry.name);
36
+ try {
37
+ const raw = await readJsonFile(filePath);
38
+ const sessionId = entry.name.slice(0, -'.json'.length);
39
+ const state = coerceState(raw, sessionId);
40
+ const cleaned = cleanExpiredTasks(state.tasks, effective, at);
41
+ if (!cleaned.length) {
42
+ await fs.rm(filePath, { force: true });
43
+ continue;
44
+ }
45
+ if (cleaned.length !== state.tasks.length) {
46
+ const next = {
47
+ ...state,
48
+ tasks: cleaned,
49
+ updatedAtMs: at
50
+ };
51
+ await writeJsonFileAtomic(filePath, next);
52
+ }
53
+ }
54
+ catch {
55
+ // best-effort: ignore per-file errors
56
+ }
57
+ }
58
+ }
59
+ catch {
60
+ // best-effort: ignore global scan errors
61
+ }
62
+ };
63
+ // Startup scan (best-effort).
64
+ void tickOnce();
65
+ if (config.tickMs > 0) {
66
+ daemonTimer = setInterval(() => {
67
+ void tickOnce();
68
+ }, config.tickMs);
69
+ daemonTimer.unref?.();
70
+ }
71
+ }
72
+ export async function stopClockDaemonForTests() {
73
+ if (daemonTimer) {
74
+ clearInterval(daemonTimer);
75
+ daemonTimer = undefined;
76
+ }
77
+ daemonStarted = false;
78
+ daemonConfig = undefined;
79
+ }
@@ -0,0 +1,2 @@
1
+ export declare function readJsonFile(filePath: string): Promise<unknown>;
2
+ export declare function writeJsonFileAtomic(filePath: string, value: unknown): Promise<void>;
@@ -0,0 +1,13 @@
1
+ import fs from 'node:fs/promises';
2
+ import path from 'node:path';
3
+ export async function readJsonFile(filePath) {
4
+ const buf = await fs.readFile(filePath);
5
+ return JSON.parse(buf.toString('utf8'));
6
+ }
7
+ export async function writeJsonFileAtomic(filePath, value) {
8
+ const dir = path.dirname(filePath);
9
+ const tmp = path.join(dir, `.tmp_${path.basename(filePath)}_${process.pid}_${Date.now()}_${Math.random().toString(16).slice(2)}`);
10
+ const payload = JSON.stringify(value, null, 2);
11
+ await fs.writeFile(tmp, payload, 'utf8');
12
+ await fs.rename(tmp, filePath);
13
+ }
@@ -0,0 +1,4 @@
1
+ export declare function readSessionDirEnv(): string;
2
+ export declare function resolveClockDir(sessionDir: string): string;
3
+ export declare function resolveClockStateFile(sessionDir: string, sessionId: string): string | null;
4
+ export declare function ensureDir(dir: string): Promise<void>;
@@ -0,0 +1,25 @@
1
+ import fs from 'node:fs/promises';
2
+ import path from 'node:path';
3
+ export function readSessionDirEnv() {
4
+ return String(process.env.ROUTECODEX_SESSION_DIR || '').trim();
5
+ }
6
+ function sanitizeSegment(value) {
7
+ return String(value || '')
8
+ .trim()
9
+ .replace(/[^a-zA-Z0-9_.-]/g, '_')
10
+ .replace(/_+/g, '_')
11
+ .replace(/^_+|_+$/g, '');
12
+ }
13
+ export function resolveClockDir(sessionDir) {
14
+ return path.join(sessionDir, 'clock');
15
+ }
16
+ export function resolveClockStateFile(sessionDir, sessionId) {
17
+ const safe = sanitizeSegment(sessionId);
18
+ if (!safe) {
19
+ return null;
20
+ }
21
+ return path.join(resolveClockDir(sessionDir), `${safe}.json`);
22
+ }
23
+ export async function ensureDir(dir) {
24
+ await fs.mkdir(dir, { recursive: true });
25
+ }
@@ -0,0 +1,3 @@
1
+ import type { ClockConfigSnapshot, ClockSessionState } from './types.js';
2
+ export declare function loadClockSessionState(sessionId: string, config: ClockConfigSnapshot): Promise<ClockSessionState>;
3
+ export declare function clearClockSession(sessionId: string): Promise<void>;