@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.
- package/dist/conversion/hub/operation-table/operation-table-runner.d.ts +18 -0
- package/dist/conversion/hub/operation-table/operation-table-runner.js +158 -0
- package/dist/conversion/hub/operation-table/semantic-mappers/anthropic-mapper.d.ts +8 -0
- package/dist/conversion/hub/operation-table/semantic-mappers/anthropic-mapper.js +303 -0
- package/dist/conversion/hub/operation-table/semantic-mappers/chat-mapper.d.ts +8 -0
- package/dist/conversion/hub/operation-table/semantic-mappers/chat-mapper.js +413 -0
- package/dist/conversion/hub/operation-table/semantic-mappers/gemini-mapper.d.ts +7 -0
- package/dist/conversion/hub/operation-table/semantic-mappers/gemini-mapper.js +841 -0
- package/dist/conversion/hub/operation-table/semantic-mappers/responses-mapper.d.ts +21 -0
- package/dist/conversion/hub/operation-table/semantic-mappers/responses-mapper.js +535 -0
- package/dist/conversion/hub/ops/operations.d.ts +19 -0
- package/dist/conversion/hub/ops/operations.js +126 -0
- package/dist/conversion/hub/pipeline/hub-pipeline.d.ts +9 -0
- package/dist/conversion/hub/pipeline/hub-pipeline.js +489 -19
- package/dist/conversion/hub/pipeline/stages/req_inbound/req_inbound_stage2_semantic_map/index.js +6 -0
- package/dist/conversion/hub/pipeline/stages/req_outbound/req_outbound_stage1_semantic_map/index.js +11 -0
- package/dist/conversion/hub/policy/policy-engine.js +41 -9
- package/dist/conversion/hub/policy/protocol-spec.d.ts +25 -0
- package/dist/conversion/hub/policy/protocol-spec.js +73 -23
- package/dist/conversion/hub/process/chat-process.js +252 -41
- package/dist/conversion/hub/response/provider-response.js +175 -2
- package/dist/conversion/hub/response/response-runtime.js +1 -1
- package/dist/conversion/hub/semantic-mappers/anthropic-mapper.d.ts +1 -8
- package/dist/conversion/hub/semantic-mappers/anthropic-mapper.js +1 -365
- package/dist/conversion/hub/semantic-mappers/chat-mapper.d.ts +1 -8
- package/dist/conversion/hub/semantic-mappers/chat-mapper.js +1 -467
- package/dist/conversion/hub/semantic-mappers/gemini-mapper.d.ts +1 -7
- package/dist/conversion/hub/semantic-mappers/gemini-mapper.js +1 -903
- package/dist/conversion/hub/semantic-mappers/responses-mapper.d.ts +1 -21
- package/dist/conversion/hub/semantic-mappers/responses-mapper.js +1 -593
- package/dist/conversion/hub/tool-surface/tool-surface-engine.d.ts +18 -0
- package/dist/conversion/hub/tool-surface/tool-surface-engine.js +571 -0
- package/dist/conversion/responses/responses-openai-bridge.js +14 -2
- package/dist/conversion/shared/bridge-message-utils.js +2 -8
- package/dist/conversion/shared/bridge-policies.js +5 -105
- package/dist/conversion/shared/gemini-tool-utils.js +89 -15
- package/dist/conversion/shared/protocol-field-allowlists.d.ts +7 -0
- package/dist/conversion/shared/protocol-field-allowlists.js +145 -0
- package/dist/conversion/shared/reasoning-tool-normalizer.js +4 -2
- package/dist/conversion/shared/snapshot-hooks.js +166 -3
- package/dist/conversion/shared/text-markup-normalizer.d.ts +2 -0
- package/dist/conversion/shared/text-markup-normalizer.js +345 -9
- package/dist/conversion/shared/thought-signature-validator.d.ts +52 -0
- package/dist/conversion/shared/thought-signature-validator.js +170 -0
- package/dist/conversion/shared/tool-argument-repairer.d.ts +39 -0
- package/dist/conversion/shared/tool-argument-repairer.js +56 -0
- package/dist/conversion/shared/tool-call-id-manager.d.ts +113 -0
- package/dist/conversion/shared/tool-call-id-manager.js +231 -0
- package/dist/conversion/shared/tool-canonicalizer.js +2 -11
- package/dist/router/virtual-router/bootstrap.js +70 -5
- package/dist/router/virtual-router/context-advisor.d.ts +4 -0
- package/dist/router/virtual-router/context-advisor.js +3 -0
- package/dist/router/virtual-router/context-weighted.d.ts +31 -0
- package/dist/router/virtual-router/context-weighted.js +54 -0
- package/dist/router/virtual-router/engine-selection.js +284 -47
- package/dist/router/virtual-router/engine.d.ts +3 -0
- package/dist/router/virtual-router/engine.js +142 -33
- package/dist/router/virtual-router/health-weighted.d.ts +25 -0
- package/dist/router/virtual-router/health-weighted.js +63 -0
- package/dist/router/virtual-router/load-balancer.d.ts +2 -0
- package/dist/router/virtual-router/load-balancer.js +45 -16
- package/dist/router/virtual-router/routing-instructions.js +17 -1
- package/dist/router/virtual-router/sticky-session-store.js +136 -24
- package/dist/router/virtual-router/stop-message-file-resolver.d.ts +1 -0
- package/dist/router/virtual-router/stop-message-file-resolver.js +74 -0
- package/dist/router/virtual-router/stop-message-state-sync.d.ts +15 -0
- package/dist/router/virtual-router/stop-message-state-sync.js +57 -0
- package/dist/router/virtual-router/types.d.ts +98 -0
- package/dist/servertool/clock/config.d.ts +7 -0
- package/dist/servertool/clock/config.js +27 -0
- package/dist/servertool/clock/daemon.d.ts +3 -0
- package/dist/servertool/clock/daemon.js +79 -0
- package/dist/servertool/clock/io.d.ts +2 -0
- package/dist/servertool/clock/io.js +13 -0
- package/dist/servertool/clock/paths.d.ts +4 -0
- package/dist/servertool/clock/paths.js +25 -0
- package/dist/servertool/clock/session-store.d.ts +3 -0
- package/dist/servertool/clock/session-store.js +56 -0
- package/dist/servertool/clock/state.d.ts +5 -0
- package/dist/servertool/clock/state.js +62 -0
- package/dist/servertool/clock/task-store.d.ts +5 -0
- package/dist/servertool/clock/task-store.js +4 -0
- package/dist/servertool/clock/tasks.d.ts +17 -0
- package/dist/servertool/clock/tasks.js +221 -0
- package/dist/servertool/clock/types.d.ts +36 -0
- package/dist/servertool/clock/types.js +1 -0
- package/dist/servertool/engine.d.ts +2 -0
- package/dist/servertool/engine.js +161 -7
- package/dist/servertool/followup-shadow.d.ts +16 -0
- package/dist/servertool/followup-shadow.js +145 -0
- package/dist/servertool/handlers/apply-patch-guard.js +1 -265
- package/dist/servertool/handlers/clock-auto.d.ts +1 -0
- package/dist/servertool/handlers/clock-auto.js +160 -0
- package/dist/servertool/handlers/clock.d.ts +1 -0
- package/dist/servertool/handlers/clock.js +197 -0
- package/dist/servertool/handlers/exec-command-guard.js +7 -555
- package/dist/servertool/handlers/followup-request-builder.d.ts +15 -7
- package/dist/servertool/handlers/followup-request-builder.js +248 -28
- package/dist/servertool/handlers/gemini-empty-reply-continue.js +62 -169
- package/dist/servertool/handlers/iflow-model-error-retry.js +18 -28
- package/dist/servertool/handlers/recursive-detection-guard.d.ts +1 -0
- package/dist/servertool/handlers/recursive-detection-guard.js +333 -0
- package/dist/servertool/handlers/stop-message-auto.js +47 -175
- package/dist/servertool/handlers/vision.d.ts +7 -1
- package/dist/servertool/handlers/vision.js +61 -117
- package/dist/servertool/handlers/web-search.d.ts +7 -1
- package/dist/servertool/handlers/web-search.js +122 -105
- package/dist/servertool/reenter-backend.d.ts +23 -0
- package/dist/servertool/reenter-backend.js +18 -0
- package/dist/servertool/server-side-tools.d.ts +3 -2
- package/dist/servertool/server-side-tools.js +64 -10
- package/dist/servertool/types.d.ts +92 -3
- package/dist/sse/json-to-sse/event-generators/responses.js +3 -21
- package/dist/sse/shared/serializers/responses-event-serializer.d.ts +8 -0
- package/dist/sse/shared/serializers/responses-event-serializer.js +19 -0
- package/dist/sse/shared/writer.js +24 -7
- package/dist/tools/apply-patch/execution-capturer.js +3 -1
- package/dist/tools/apply-patch/json/parse-loose.d.ts +3 -0
- package/dist/tools/apply-patch/json/parse-loose.js +139 -0
- package/dist/tools/apply-patch/patch-text/context-diff.d.ts +1 -0
- package/dist/tools/apply-patch/patch-text/context-diff.js +173 -0
- package/dist/tools/apply-patch/patch-text/git-diff.d.ts +1 -0
- package/dist/tools/apply-patch/patch-text/git-diff.js +138 -0
- package/dist/tools/apply-patch/patch-text/looks-like-patch.d.ts +1 -0
- package/dist/tools/apply-patch/patch-text/looks-like-patch.js +13 -0
- package/dist/tools/apply-patch/patch-text/normalize.d.ts +3 -0
- package/dist/tools/apply-patch/patch-text/normalize.js +262 -0
- package/dist/tools/apply-patch/structured/coercion.d.ts +3 -0
- package/dist/tools/apply-patch/structured/coercion.js +82 -0
- package/dist/tools/apply-patch/validation/shared.d.ts +3 -0
- package/dist/tools/apply-patch/validation/shared.js +6 -0
- package/dist/tools/apply-patch/validator.d.ts +2 -2
- package/dist/tools/apply-patch/validator.js +6 -556
- 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
|
-
|
|
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
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
}
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
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
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
}
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
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
|
-
|
|
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,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,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>;
|