@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.
- 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 +533 -24
- package/dist/conversion/hub/pipeline/stages/req_inbound/req_inbound_stage2_semantic_map/index.js +6 -0
- package/dist/conversion/hub/pipeline/stages/req_inbound/req_inbound_stage3_context_capture/index.js +6 -3
- 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 -436
- package/dist/conversion/hub/semantic-mappers/gemini-mapper.d.ts +1 -7
- package/dist/conversion/hub/semantic-mappers/gemini-mapper.js +1 -894
- 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 +121 -4
- 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 +54 -5
- package/dist/router/virtual-router/engine-selection.js +132 -42
- 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 +70 -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 +164 -8
- 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
|
@@ -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
|
-
|
|
35
|
-
|
|
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
|
|
38
|
-
const
|
|
39
|
-
|
|
40
|
-
let
|
|
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
|
-
|
|
43
|
-
|
|
44
|
-
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
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,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
|
+
}
|