mslxdff 0.1.45 → 0.1.55
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/README.md +162 -162
- package/bin/mslxdff.js +202 -21
- package/docs/adr/0001-reasoning-content-injection.md +13 -13
- package/docs/adr/0002-models-free-filter.md +11 -11
- package/docs/adr/0003-zero-state-no-auth.md +9 -9
- package/docs/adr/0004-bearer-token.md +17 -17
- package/docs/agents/domain.md +50 -50
- package/docs/agents/issue-tracker.md +29 -29
- package/docs/agents/triage-labels.md +14 -14
- package/package.json +1 -1
- package/src/auto.js +20 -2
- package/src/chooser.js +12 -6
- package/src/daemon.js +84 -84
- package/src/logs.js +41 -2
- package/src/models.js +127 -127
- package/src/reasoning.js +32 -32
- package/src/routes/chat/broadband-handler.js +88 -0
- package/src/routes/chat/exhausted-handler.js +41 -0
- package/src/routes/chat/hedge-handler.js +142 -0
- package/src/routes/chat/index.js +178 -0
- package/src/routes/chat/local-handler.js +88 -0
- package/src/routes/chat/peer-handler.js +58 -0
- package/src/routes/chat.js +3 -342
- package/src/routes/hedge.js +251 -0
- package/src/routes/peers.js +49 -15
- package/src/server.js +57 -57
- package/src/state.js +285 -152
- package/src/sync-workbuddy.js +104 -0
- package/src/upstream.js +311 -212
package/src/models.js
CHANGED
|
@@ -1,128 +1,128 @@
|
|
|
1
|
-
const KNOWN_FREE_OPENCODE_MODELS = ["big-pickle"];
|
|
2
|
-
const CACHE_TTL_MS = 2 * 60 * 60 * 1000;
|
|
3
|
-
const DEFAULT_REFRESH_MS = 2 * 60 * 60 * 1000;
|
|
4
|
-
import { mkdirSync, writeFileSync } from "node:fs";
|
|
5
|
-
import { dirname } from "node:path";
|
|
6
|
-
|
|
7
|
-
export function isFreeModel(id) {
|
|
8
|
-
return (typeof id === "string" && id.endsWith("-free")) ||
|
|
9
|
-
KNOWN_FREE_OPENCODE_MODELS.includes(id);
|
|
10
|
-
}
|
|
11
|
-
|
|
12
|
-
export function filterFreeModels(list) {
|
|
13
|
-
const seen = new Set();
|
|
14
|
-
const out = [];
|
|
15
|
-
for (const m of list || []) {
|
|
16
|
-
if (!(m && m.id)) continue;
|
|
17
|
-
if (!isFreeModel(m.id)) continue;
|
|
18
|
-
if (seen.has(m.id)) continue;
|
|
19
|
-
seen.add(m.id);
|
|
20
|
-
out.push(m);
|
|
21
|
-
}
|
|
22
|
-
return out;
|
|
23
|
-
}
|
|
24
|
-
|
|
25
|
-
export function createModelsService({ baseUrl, headers, ttlMs = CACHE_TTL_MS, refreshMs = DEFAULT_REFRESH_MS, cacheFile } = {}) {
|
|
26
|
-
let cache = null;
|
|
27
|
-
let fetchedAt = 0;
|
|
28
|
-
let inflight = null;
|
|
29
|
-
let timer = null;
|
|
30
|
-
|
|
31
|
-
async function load() {
|
|
32
|
-
const data = await fetchUpstreamModels({ baseUrl, headers });
|
|
33
|
-
cache = data;
|
|
34
|
-
fetchedAt = Date.now();
|
|
35
|
-
if (cacheFile) persistModels(data, cacheFile);
|
|
36
|
-
return data;
|
|
37
|
-
}
|
|
38
|
-
|
|
39
|
-
async function get() {
|
|
40
|
-
const now = Date.now();
|
|
41
|
-
if (cache && now - fetchedAt < ttlMs) return cache;
|
|
42
|
-
if (inflight) return inflight;
|
|
43
|
-
|
|
44
|
-
inflight = (async () => {
|
|
45
|
-
try {
|
|
46
|
-
return await load();
|
|
47
|
-
} catch (err) {
|
|
48
|
-
// serve stale on failure if we have it, else rethrow
|
|
49
|
-
if (cache) return cache;
|
|
50
|
-
throw err;
|
|
51
|
-
} finally {
|
|
52
|
-
inflight = null;
|
|
53
|
-
}
|
|
54
|
-
})();
|
|
55
|
-
return inflight;
|
|
56
|
-
}
|
|
57
|
-
|
|
58
|
-
function startAutoRefresh(intervalMs = refreshMs) {
|
|
59
|
-
if (timer) return stopAutoRefresh;
|
|
60
|
-
timer = setInterval(() => {
|
|
61
|
-
void load().catch(() => {
|
|
62
|
-
// keep serving stale cache on background refresh failure
|
|
63
|
-
});
|
|
64
|
-
}, intervalMs);
|
|
65
|
-
timer.unref?.();
|
|
66
|
-
return stopAutoRefresh;
|
|
67
|
-
}
|
|
68
|
-
|
|
69
|
-
function stopAutoRefresh() {
|
|
70
|
-
if (timer) {
|
|
71
|
-
clearInterval(timer);
|
|
72
|
-
timer = null;
|
|
73
|
-
}
|
|
74
|
-
}
|
|
75
|
-
|
|
76
|
-
return { get, startAutoRefresh, stopAutoRefresh };
|
|
77
|
-
}
|
|
78
|
-
|
|
79
|
-
async function fetchUpstreamModels({ baseUrl, headers, connectTimeoutMs = 30_000 }) {
|
|
80
|
-
const url = `${baseUrl}/zen/v1/models`;
|
|
81
|
-
for (let attempt = 0; ; attempt++) {
|
|
82
|
-
const res = await attemptFetch(url, headers, connectTimeoutMs);
|
|
83
|
-
if (res instanceof Error) {
|
|
84
|
-
if (attempt < NETWORK_RETRIES) continue;
|
|
85
|
-
throw res;
|
|
86
|
-
}
|
|
87
|
-
if (isRetryable(res.status) && attempt < STATUS_RETRIES) {
|
|
88
|
-
await sleep(2000);
|
|
89
|
-
continue;
|
|
90
|
-
}
|
|
91
|
-
if (!res.ok) throw new Error(`models fetch failed: HTTP ${res.status}`);
|
|
92
|
-
const json = await res.json().catch(() => ({}));
|
|
93
|
-
const raw = Array.isArray(json) ? json : json.data ?? json.models ?? [];
|
|
94
|
-
return { object: "list", data: filterFreeModels(raw) };
|
|
95
|
-
}
|
|
96
|
-
}
|
|
97
|
-
|
|
98
|
-
async function attemptFetch(url, headers, connectTimeoutMs) {
|
|
99
|
-
const controller = new AbortController();
|
|
100
|
-
const timer = setTimeout(() => controller.abort(), connectTimeoutMs);
|
|
101
|
-
try {
|
|
102
|
-
return await fetch(url, { headers, signal: controller.signal });
|
|
103
|
-
} catch (err) {
|
|
104
|
-
return err;
|
|
105
|
-
} finally {
|
|
106
|
-
clearTimeout(timer);
|
|
107
|
-
}
|
|
108
|
-
}
|
|
109
|
-
|
|
110
|
-
function isRetryable(status) {
|
|
111
|
-
return status === 429 || status === 502 || status === 503 || status === 504;
|
|
112
|
-
}
|
|
113
|
-
|
|
114
|
-
function persistModels(data, cacheFile) {
|
|
115
|
-
try {
|
|
116
|
-
mkdirSync(dirname(cacheFile), { recursive: true });
|
|
117
|
-
writeFileSync(cacheFile, JSON.stringify({ cachedAt: Date.now(), ...data }));
|
|
118
|
-
} catch {
|
|
119
|
-
// persistence is best-effort
|
|
120
|
-
}
|
|
121
|
-
}
|
|
122
|
-
|
|
123
|
-
function sleep(ms) {
|
|
124
|
-
return new Promise((r) => setTimeout(r, ms));
|
|
125
|
-
}
|
|
126
|
-
|
|
127
|
-
const NETWORK_RETRIES = 2;
|
|
1
|
+
const KNOWN_FREE_OPENCODE_MODELS = ["big-pickle"];
|
|
2
|
+
const CACHE_TTL_MS = 2 * 60 * 60 * 1000;
|
|
3
|
+
const DEFAULT_REFRESH_MS = 2 * 60 * 60 * 1000;
|
|
4
|
+
import { mkdirSync, writeFileSync } from "node:fs";
|
|
5
|
+
import { dirname } from "node:path";
|
|
6
|
+
|
|
7
|
+
export function isFreeModel(id) {
|
|
8
|
+
return (typeof id === "string" && id.endsWith("-free")) ||
|
|
9
|
+
KNOWN_FREE_OPENCODE_MODELS.includes(id);
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function filterFreeModels(list) {
|
|
13
|
+
const seen = new Set();
|
|
14
|
+
const out = [];
|
|
15
|
+
for (const m of list || []) {
|
|
16
|
+
if (!(m && m.id)) continue;
|
|
17
|
+
if (!isFreeModel(m.id)) continue;
|
|
18
|
+
if (seen.has(m.id)) continue;
|
|
19
|
+
seen.add(m.id);
|
|
20
|
+
out.push(m);
|
|
21
|
+
}
|
|
22
|
+
return out;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function createModelsService({ baseUrl, headers, ttlMs = CACHE_TTL_MS, refreshMs = DEFAULT_REFRESH_MS, cacheFile } = {}) {
|
|
26
|
+
let cache = null;
|
|
27
|
+
let fetchedAt = 0;
|
|
28
|
+
let inflight = null;
|
|
29
|
+
let timer = null;
|
|
30
|
+
|
|
31
|
+
async function load() {
|
|
32
|
+
const data = await fetchUpstreamModels({ baseUrl, headers });
|
|
33
|
+
cache = data;
|
|
34
|
+
fetchedAt = Date.now();
|
|
35
|
+
if (cacheFile) persistModels(data, cacheFile);
|
|
36
|
+
return data;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
async function get() {
|
|
40
|
+
const now = Date.now();
|
|
41
|
+
if (cache && now - fetchedAt < ttlMs) return cache;
|
|
42
|
+
if (inflight) return inflight;
|
|
43
|
+
|
|
44
|
+
inflight = (async () => {
|
|
45
|
+
try {
|
|
46
|
+
return await load();
|
|
47
|
+
} catch (err) {
|
|
48
|
+
// serve stale on failure if we have it, else rethrow
|
|
49
|
+
if (cache) return cache;
|
|
50
|
+
throw err;
|
|
51
|
+
} finally {
|
|
52
|
+
inflight = null;
|
|
53
|
+
}
|
|
54
|
+
})();
|
|
55
|
+
return inflight;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function startAutoRefresh(intervalMs = refreshMs) {
|
|
59
|
+
if (timer) return stopAutoRefresh;
|
|
60
|
+
timer = setInterval(() => {
|
|
61
|
+
void load().catch(() => {
|
|
62
|
+
// keep serving stale cache on background refresh failure
|
|
63
|
+
});
|
|
64
|
+
}, intervalMs);
|
|
65
|
+
timer.unref?.();
|
|
66
|
+
return stopAutoRefresh;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function stopAutoRefresh() {
|
|
70
|
+
if (timer) {
|
|
71
|
+
clearInterval(timer);
|
|
72
|
+
timer = null;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
return { get, startAutoRefresh, stopAutoRefresh };
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
async function fetchUpstreamModels({ baseUrl, headers, connectTimeoutMs = 30_000 }) {
|
|
80
|
+
const url = `${baseUrl}/zen/v1/models`;
|
|
81
|
+
for (let attempt = 0; ; attempt++) {
|
|
82
|
+
const res = await attemptFetch(url, headers, connectTimeoutMs);
|
|
83
|
+
if (res instanceof Error) {
|
|
84
|
+
if (attempt < NETWORK_RETRIES) continue;
|
|
85
|
+
throw res;
|
|
86
|
+
}
|
|
87
|
+
if (isRetryable(res.status) && attempt < STATUS_RETRIES) {
|
|
88
|
+
await sleep(2000);
|
|
89
|
+
continue;
|
|
90
|
+
}
|
|
91
|
+
if (!res.ok) throw new Error(`models fetch failed: HTTP ${res.status}`);
|
|
92
|
+
const json = await res.json().catch(() => ({}));
|
|
93
|
+
const raw = Array.isArray(json) ? json : json.data ?? json.models ?? [];
|
|
94
|
+
return { object: "list", data: filterFreeModels(raw) };
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
async function attemptFetch(url, headers, connectTimeoutMs) {
|
|
99
|
+
const controller = new AbortController();
|
|
100
|
+
const timer = setTimeout(() => controller.abort(), connectTimeoutMs);
|
|
101
|
+
try {
|
|
102
|
+
return await fetch(url, { headers, signal: controller.signal });
|
|
103
|
+
} catch (err) {
|
|
104
|
+
return err;
|
|
105
|
+
} finally {
|
|
106
|
+
clearTimeout(timer);
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function isRetryable(status) {
|
|
111
|
+
return status === 429 || status === 502 || status === 503 || status === 504;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function persistModels(data, cacheFile) {
|
|
115
|
+
try {
|
|
116
|
+
mkdirSync(dirname(cacheFile), { recursive: true });
|
|
117
|
+
writeFileSync(cacheFile, JSON.stringify({ cachedAt: Date.now(), ...data }));
|
|
118
|
+
} catch {
|
|
119
|
+
// persistence is best-effort
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function sleep(ms) {
|
|
124
|
+
return new Promise((r) => setTimeout(r, ms));
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
const NETWORK_RETRIES = 2;
|
|
128
128
|
const STATUS_RETRIES = 2;
|
package/src/reasoning.js
CHANGED
|
@@ -1,33 +1,33 @@
|
|
|
1
|
-
const PLACEHOLDER = " ";
|
|
2
|
-
|
|
3
|
-
const MODEL_RULES = [
|
|
4
|
-
{ match: (m) => /^kimi-/i.test(m || ""), scope: "toolCalls" },
|
|
5
|
-
{ match: (m) => /deepseek/i.test(m || ""), scope: "all" },
|
|
6
|
-
];
|
|
7
|
-
|
|
8
|
-
export function normalizeModel(model) {
|
|
9
|
-
return model.startsWith("oc/") ? model.slice(3) : model;
|
|
10
|
-
}
|
|
11
|
-
|
|
12
|
-
function shouldInject(message, scope) {
|
|
13
|
-
if (message?.role !== "assistant") return false;
|
|
14
|
-
const rc = message.reasoning_content;
|
|
15
|
-
if (typeof rc === "string" && rc.length > 0) return false;
|
|
16
|
-
if (scope === "toolCalls") {
|
|
17
|
-
return Array.isArray(message.tool_calls) && message.tool_calls.length > 0;
|
|
18
|
-
}
|
|
19
|
-
return true;
|
|
20
|
-
}
|
|
21
|
-
|
|
22
|
-
function applyRule(body, rule) {
|
|
23
|
-
if (!rule || !body?.messages) return body;
|
|
24
|
-
const messages = body.messages.map((m) =>
|
|
25
|
-
shouldInject(m, rule.scope) ? { ...m, reasoning_content: PLACEHOLDER } : m
|
|
26
|
-
);
|
|
27
|
-
return { ...body, messages };
|
|
28
|
-
}
|
|
29
|
-
|
|
30
|
-
export function injectReasoningContent(model, body) {
|
|
31
|
-
const rule = MODEL_RULES.find((r) => r.match(model));
|
|
32
|
-
return applyRule(body, rule);
|
|
1
|
+
const PLACEHOLDER = " ";
|
|
2
|
+
|
|
3
|
+
const MODEL_RULES = [
|
|
4
|
+
{ match: (m) => /^kimi-/i.test(m || ""), scope: "toolCalls" },
|
|
5
|
+
{ match: (m) => /deepseek/i.test(m || ""), scope: "all" },
|
|
6
|
+
];
|
|
7
|
+
|
|
8
|
+
export function normalizeModel(model) {
|
|
9
|
+
return model.startsWith("oc/") ? model.slice(3) : model;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
function shouldInject(message, scope) {
|
|
13
|
+
if (message?.role !== "assistant") return false;
|
|
14
|
+
const rc = message.reasoning_content;
|
|
15
|
+
if (typeof rc === "string" && rc.length > 0) return false;
|
|
16
|
+
if (scope === "toolCalls") {
|
|
17
|
+
return Array.isArray(message.tool_calls) && message.tool_calls.length > 0;
|
|
18
|
+
}
|
|
19
|
+
return true;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function applyRule(body, rule) {
|
|
23
|
+
if (!rule || !body?.messages) return body;
|
|
24
|
+
const messages = body.messages.map((m) =>
|
|
25
|
+
shouldInject(m, rule.scope) ? { ...m, reasoning_content: PLACEHOLDER } : m
|
|
26
|
+
);
|
|
27
|
+
return { ...body, messages };
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function injectReasoningContent(model, body) {
|
|
31
|
+
const rule = MODEL_RULES.find((r) => r.match(model));
|
|
32
|
+
return applyRule(body, rule);
|
|
33
33
|
}
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import { buildFallbackInfo } from "../fallback.js";
|
|
2
|
+
import { relay, SLOW_TOTAL_MS } from "../stream.js";
|
|
3
|
+
import { tryBroadbandRelay } from "../relay-queue.js";
|
|
4
|
+
import { runHook } from "../../plugins.js";
|
|
5
|
+
|
|
6
|
+
export async function handleBroadbandRelay({
|
|
7
|
+
model,
|
|
8
|
+
body,
|
|
9
|
+
hops,
|
|
10
|
+
lastErr,
|
|
11
|
+
requested,
|
|
12
|
+
useAuto,
|
|
13
|
+
lockModel,
|
|
14
|
+
auto,
|
|
15
|
+
groups,
|
|
16
|
+
token,
|
|
17
|
+
bus,
|
|
18
|
+
logs,
|
|
19
|
+
handlerCtx,
|
|
20
|
+
evt,
|
|
21
|
+
mark,
|
|
22
|
+
perf0,
|
|
23
|
+
stages,
|
|
24
|
+
res,
|
|
25
|
+
startedAt,
|
|
26
|
+
plugins,
|
|
27
|
+
}) {
|
|
28
|
+
const bb = await tryBroadbandRelay({ groups, token, model, body, hops, bus, logs, reqId: handlerCtx.reqId, evt, res, mark, perf0, stages });
|
|
29
|
+
if (!bb) {
|
|
30
|
+
evt("relay-miss", { reqId: handlerCtx.reqId, model });
|
|
31
|
+
return { handled: false };
|
|
32
|
+
}
|
|
33
|
+
const isResponse = bb.result && typeof bb.result.status === "number" && typeof bb.result.headers?.get === "function";
|
|
34
|
+
if (isResponse) {
|
|
35
|
+
const bbFallback = buildFallbackInfo({ requested, actual: model, lastErr, via: "broadband", useAuto, lockModel });
|
|
36
|
+
if (bbFallback?.fallback) evt("fallback-notice", { reqId: handlerCtx.reqId, requested, actual: model, reason: bbFallback.reason, notice: bbFallback.notice, via: "broadband" });
|
|
37
|
+
evt("relay-start", { reqId: handlerCtx.reqId, model, via: "broadband", target: bb.target, group: bb.group, fallback: bbFallback });
|
|
38
|
+
const out = await relay(res, bb.result, body, {
|
|
39
|
+
fallback: bbFallback,
|
|
40
|
+
onFirstChunk: (d) => mark(`ttf-bb-${model}`),
|
|
41
|
+
onDownstreamAbort: () => evt("client-abort", { reqId: handlerCtx.reqId, model, totalMs: Math.round(Date.now() - startedAt), stages: [...stages] }),
|
|
42
|
+
});
|
|
43
|
+
evt("relay-done", { reqId: handlerCtx.reqId, model, via: "broadband", status: out.status, ttfMs: out.ttfMs, totalMs: out.totalMs, aborted: out.aborted, interrupted: out.interrupted ?? false, detail: out.detail ?? null });
|
|
44
|
+
if (auto && out.status === 200) {
|
|
45
|
+
const latencyMs = out.totalMs ?? 0;
|
|
46
|
+
if (out.detail?.stallHits > 0 || (latencyMs && latencyMs > SLOW_TOTAL_MS)) {
|
|
47
|
+
void auto.recordError(model, { status: 200, slow: true, note: `broadband slow ${latencyMs}ms` });
|
|
48
|
+
void auto.recordLatency(model, latencyMs);
|
|
49
|
+
} else {
|
|
50
|
+
await auto.recordOk(model, { latencyMs });
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
evt("result", { model, status: out.status, via: "broadband", timing: bb.result._t ?? null, ttfMs: out.ttfMs, totalMs: out.totalMs, detail: out.detail ?? null, fallback: bbFallback, requested, actual: model });
|
|
54
|
+
evt("client-response", { requested, actual: model, via: "broadband", fallback: bbFallback, status: out.status, reqId: handlerCtx.reqId });
|
|
55
|
+
if (plugins?.length) runHook(plugins, "request:completed", { reqId: handlerCtx.reqId, requested, useAuto, hops: handlerCtx.hops, stream: Boolean(body.stream), durationMs: Date.now() - startedAt, via: "broadband", status: out.status, actual: model, fallback: bbFallback }).catch(() => {});
|
|
56
|
+
return { handled: true };
|
|
57
|
+
} else if (bb.result && typeof bb.result.status === "number") {
|
|
58
|
+
const fakeRes = {
|
|
59
|
+
status: bb.result.status,
|
|
60
|
+
headers: { get: (k) => bb.result.headers?.[k] || bb.result.headers?.[k.toLowerCase()] || null },
|
|
61
|
+
text: async () => typeof bb.result.body === "string" ? bb.result.body : JSON.stringify(bb.result.body),
|
|
62
|
+
body: (() => {
|
|
63
|
+
const b = bb.result.body || "";
|
|
64
|
+
const str = typeof b === "string" ? b : JSON.stringify(b);
|
|
65
|
+
const isSSE = bb.result.headers?.["Content-Type"]?.includes("text/event-stream");
|
|
66
|
+
if (isSSE) {
|
|
67
|
+
return (async function* () { yield Buffer.from(str); })();
|
|
68
|
+
}
|
|
69
|
+
return null;
|
|
70
|
+
})(),
|
|
71
|
+
};
|
|
72
|
+
const bbLocalFallback = buildFallbackInfo({ requested, actual: model, lastErr, via: "broadband", useAuto, lockModel });
|
|
73
|
+
if (bbLocalFallback?.fallback) evt("fallback-notice", { reqId: handlerCtx.reqId, requested, actual: model, reason: bbLocalFallback.reason, notice: bbLocalFallback.notice, via: "broadband" });
|
|
74
|
+
evt("relay-start", { reqId: handlerCtx.reqId, model, via: "broadband-local", target: bb.target, group: bb.group, fallback: bbLocalFallback });
|
|
75
|
+
const out = await relay(res, fakeRes, body, {
|
|
76
|
+
fallback: bbLocalFallback,
|
|
77
|
+
onFirstChunk: (d) => mark(`ttf-bb-${model}`),
|
|
78
|
+
onDownstreamAbort: () => evt("client-abort", { reqId: handlerCtx.reqId, model, totalMs: Math.round(Date.now() - startedAt), stages: [...stages] }),
|
|
79
|
+
});
|
|
80
|
+
evt("relay-done", { reqId: handlerCtx.reqId, model, via: "broadband-local", status: out.status, ttfMs: out.ttfMs, totalMs: out.totalMs, aborted: out.aborted, interrupted: out.interrupted ?? false, detail: out.detail ?? null });
|
|
81
|
+
evt("result", { model, status: out.status, via: "broadband", timing: null, ttfMs: out.ttfMs, totalMs: out.totalMs, detail: out.detail ?? null, fallback: bbLocalFallback, requested, actual: model });
|
|
82
|
+
evt("client-response", { requested, actual: model, via: "broadband", fallback: bbLocalFallback, status: out.status, reqId: handlerCtx.reqId });
|
|
83
|
+
if (plugins?.length) runHook(plugins, "request:completed", { reqId: handlerCtx.reqId, requested, useAuto, hops: handlerCtx.hops, stream: Boolean(body.stream), durationMs: Date.now() - startedAt, via: "broadband", status: out.status, actual: model, fallback: bbLocalFallback }).catch(() => {});
|
|
84
|
+
return { handled: true };
|
|
85
|
+
}
|
|
86
|
+
evt("relay-miss", { reqId: handlerCtx.reqId, model });
|
|
87
|
+
return { handled: false };
|
|
88
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { performance } from "node:perf_hooks";
|
|
2
|
+
import { json } from "../helpers.js";
|
|
3
|
+
import { relay } from "../stream.js";
|
|
4
|
+
|
|
5
|
+
export async function handleExhaustedLocal({ res, body, lastErr, order, handlerCtx, evt, logCall, mark, perf0, stages, done, requested, useAuto }) {
|
|
6
|
+
const model = lastErr?.model ?? handlerCtx.model;
|
|
7
|
+
evt("exhausted-local", { reqId: handlerCtx.reqId, lastModel: lastErr?.model ?? model, lastStatus: lastErr?.status ?? 502, order });
|
|
8
|
+
logCall(lastErr?.model ?? model, lastErr?.status ?? 502);
|
|
9
|
+
if (lastErr?.upstream) {
|
|
10
|
+
evt("relay-start", { reqId: handlerCtx.reqId, model: lastErr.model, via: "local-exhausted", isStream: Boolean(body.stream) });
|
|
11
|
+
const out = await relay(res, lastErr.upstream, body, {
|
|
12
|
+
onFirstChunk: (d) => mark(`ttf-${lastErr.model}`),
|
|
13
|
+
onDownstreamAbort: () => evt("client-abort", { reqId: handlerCtx.reqId, model: lastErr.model, totalMs: Math.round(performance.now() - perf0), stages: [...stages] }),
|
|
14
|
+
});
|
|
15
|
+
evt("relay-done", { reqId: handlerCtx.reqId, model: lastErr.model, via: "local-exhausted", status: out.status, ttfMs: out.ttfMs, totalMs: out.totalMs, aborted: out.aborted, interrupted: out.interrupted ?? false, detail: out.detail ?? null });
|
|
16
|
+
evt("result", { reqId: handlerCtx.reqId, model: lastErr.model, status: out.status, via: "local", timing: lastErr.upstream._t ?? null, ttfMs: out.ttfMs, totalMs: out.totalMs, detail: out.detail ?? null });
|
|
17
|
+
return true;
|
|
18
|
+
}
|
|
19
|
+
evt("result", { reqId: handlerCtx.reqId, model, status: lastErr?.status ?? 502, via: "none", timing: null });
|
|
20
|
+
done({ via: "none", status: lastErr?.status ?? 502, error: lastErr?.message || "all auto models failed" });
|
|
21
|
+
json(res, 502, { error: lastErr?.message || "all auto models failed" });
|
|
22
|
+
return true;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export async function handleExhaustedAll({ res, body, lastErr, order, requested, handlerCtx, evt, logCall, mark, perf0, stages }) {
|
|
26
|
+
evt("exhausted-all", { reqId: handlerCtx.reqId, lastModel: lastErr?.model ?? requested, lastStatus: lastErr?.status ?? 502, order });
|
|
27
|
+
logCall(lastErr?.model ?? requested, lastErr?.status ?? 502);
|
|
28
|
+
if (lastErr?.upstream) {
|
|
29
|
+
evt("relay-start", { reqId: handlerCtx.reqId, model: lastErr.model, via: "local-final", isStream: Boolean(body.stream) });
|
|
30
|
+
const out = await relay(res, lastErr.upstream, body, {
|
|
31
|
+
onFirstChunk: (d) => mark(`ttf-${lastErr.model}`),
|
|
32
|
+
onDownstreamAbort: () => evt("client-abort", { reqId: handlerCtx.reqId, model: lastErr.model, totalMs: Math.round(performance.now() - perf0), stages: [...stages] }),
|
|
33
|
+
});
|
|
34
|
+
evt("relay-done", { reqId: handlerCtx.reqId, model: lastErr.model, via: "local-final", status: out.status, ttfMs: out.ttfMs, totalMs: out.totalMs, aborted: out.aborted, interrupted: out.interrupted ?? false, detail: out.detail ?? null });
|
|
35
|
+
evt("result", { reqId: handlerCtx.reqId, model: lastErr.model, status: out.status, via: "local", timing: lastErr.upstream._t ?? null, ttfMs: out.ttfMs, totalMs: out.totalMs, detail: out.detail ?? null });
|
|
36
|
+
return true;
|
|
37
|
+
}
|
|
38
|
+
evt("result", { reqId: handlerCtx.reqId, model: lastErr?.model ?? requested, status: lastErr?.status ?? 502, via: "none", timing: null });
|
|
39
|
+
json(res, 502, { error: lastErr?.message || "all auto models failed" });
|
|
40
|
+
return true;
|
|
41
|
+
}
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
import { buildFallbackInfo } from "../fallback.js";
|
|
2
|
+
import { relay, SLOW_TOTAL_MS, STREAM_TIMEOUT_MS, STALL_TIMEOUT_MS, SCORE_STALL_MS } from "../stream.js";
|
|
3
|
+
import { hedgedFirstChunkRace } from "../hedge.js";
|
|
4
|
+
import { runHook } from "../../plugins.js";
|
|
5
|
+
|
|
6
|
+
export async function handleHedge({
|
|
7
|
+
upRes,
|
|
8
|
+
model,
|
|
9
|
+
body,
|
|
10
|
+
order,
|
|
11
|
+
idx,
|
|
12
|
+
lastErr,
|
|
13
|
+
requested,
|
|
14
|
+
useAuto,
|
|
15
|
+
lockModel,
|
|
16
|
+
auto,
|
|
17
|
+
peers,
|
|
18
|
+
handlerCtx,
|
|
19
|
+
evt,
|
|
20
|
+
logCall,
|
|
21
|
+
logError,
|
|
22
|
+
mark,
|
|
23
|
+
perf0,
|
|
24
|
+
stages,
|
|
25
|
+
startedAt,
|
|
26
|
+
plugins,
|
|
27
|
+
res,
|
|
28
|
+
hedgeDelayMs,
|
|
29
|
+
}) {
|
|
30
|
+
const isStream = Boolean(body.stream);
|
|
31
|
+
const d = hedgeDelayMs;
|
|
32
|
+
// hedge 已在外层判断 doHedge,这里直接执行赛跑
|
|
33
|
+
try {
|
|
34
|
+
const hedged = await hedgedFirstChunkRace({ localUpRes: upRes, peers, handlerCtx, hedgeDelayMs: d, evt });
|
|
35
|
+
if (hedged && hedged.winner) {
|
|
36
|
+
if (hedged.winner === "local") {
|
|
37
|
+
const fallback = buildFallbackInfo({ requested, actual: model, lastErr, via: "local", useAuto, lockModel });
|
|
38
|
+
if (fallback?.fallback) evt("fallback-notice", { reqId: handlerCtx.reqId, requested, actual: model, reason: fallback.reason, notice: fallback.notice, via: "local" });
|
|
39
|
+
evt("relay-start", { reqId: handlerCtx.reqId, model, via: "local", isStream, fallback, hedged: true });
|
|
40
|
+
const bufferedUpRes = { ...upRes, body: hedged.bufferedBody, headers: upRes.headers, status: upRes.status, _t: upRes._t };
|
|
41
|
+
logCall(model, bufferedUpRes.status);
|
|
42
|
+
const out = await relay(res, bufferedUpRes, body, {
|
|
43
|
+
fallback,
|
|
44
|
+
onFirstChunk: (delta) => {
|
|
45
|
+
mark(`ttf-${model}`);
|
|
46
|
+
evt("relay-first-chunk", { reqId: handlerCtx.reqId, model, ttfMs: delta, hedged: true, via: "local" });
|
|
47
|
+
if (plugins?.length) runHook(plugins, "relay:first-chunk", { reqId: handlerCtx.reqId, requested, model, via: "local", ttfMs: delta }).catch(() => {});
|
|
48
|
+
},
|
|
49
|
+
onDownstreamAbort: () => {
|
|
50
|
+
evt("client-abort", { reqId: handlerCtx.reqId, model, totalMs: Math.round(perf0 ? (Date.now() - startedAt) : 0), stages: [...stages] });
|
|
51
|
+
},
|
|
52
|
+
});
|
|
53
|
+
evt("relay-done", { reqId: handlerCtx.reqId, model, via: "local", status: out.status, ttfMs: out.ttfMs ?? hedged.ttfMs, totalMs: out.totalMs, aborted: out.aborted, interrupted: out.interrupted ?? false, detail: out.detail ?? null, hedged: true });
|
|
54
|
+
if (out.status === STREAM_TIMEOUT_MS) {
|
|
55
|
+
if (auto) await auto.recordError(model, { status: 502, slow: true, note: `stream timeout ${STREAM_TIMEOUT_MS}ms` });
|
|
56
|
+
const err = { model, upstream: null, status: 502, message: `stream timed out after ${STREAM_TIMEOUT_MS}ms` };
|
|
57
|
+
logError(model, 502, `stream timeout ${STREAM_TIMEOUT_MS}ms`);
|
|
58
|
+
evt("upstream-error", { reqId: handlerCtx.reqId, model, status: 502, message: "stream timeout", timing: null });
|
|
59
|
+
evt("fallback", { reqId: handlerCtx.reqId, from: model, to: order[idx + 1] ?? null, reason: "stream timeout" });
|
|
60
|
+
return { handled: false, upRes: null, lastErr: err };
|
|
61
|
+
}
|
|
62
|
+
if (out.interrupted) {
|
|
63
|
+
if (auto) {
|
|
64
|
+
await auto.recordError(model, { status: 200, slow: true, note: `stall ${STALL_TIMEOUT_MS}ms` });
|
|
65
|
+
await auto.recordLatency(model, out.totalMs ?? (Date.now() - startedAt));
|
|
66
|
+
}
|
|
67
|
+
evt("slow-model", { model, elapsedMs: out.totalMs ?? (Date.now() - startedAt), threshold: STALL_TIMEOUT_MS, interrupted: true, detail: out.detail ?? null });
|
|
68
|
+
logCall(model, 200);
|
|
69
|
+
evt("result", { model, status: out.status, via: "local", timing: upRes._t ?? null, ttfMs: out.ttfMs, totalMs: out.totalMs, interrupted: true, detail: out.detail ?? null, fallback, requested, actual: model });
|
|
70
|
+
evt("client-response", { requested, actual: model, via: "local", fallback, status: out.status, reqId: handlerCtx.reqId });
|
|
71
|
+
if (plugins?.length) runHook(plugins, "request:completed", { reqId: handlerCtx.reqId, requested, useAuto, hops: handlerCtx.hops, stream: isStream, durationMs: Date.now() - startedAt, via: "local", status: out.status, actual: model, interrupted: true, fallback }).catch(() => {});
|
|
72
|
+
return { handled: true };
|
|
73
|
+
}
|
|
74
|
+
const elapsed = Date.now() - startedAt;
|
|
75
|
+
const latencyMs = out.totalMs ?? elapsed;
|
|
76
|
+
let scoredSlow = false;
|
|
77
|
+
if (SLOW_TOTAL_MS && auto && elapsed > SLOW_TOTAL_MS && out.status === 200) {
|
|
78
|
+
void auto.recordError(model, { status: 200, slow: true, note: `slow ${elapsed}ms` });
|
|
79
|
+
void auto.recordLatency(model, latencyMs);
|
|
80
|
+
evt("slow-model", { model, elapsedMs: elapsed, threshold: SLOW_TOTAL_MS, reason: "total", detail: out.detail ?? null });
|
|
81
|
+
scoredSlow = true;
|
|
82
|
+
}
|
|
83
|
+
if (out.detail?.stallHits > 0 && auto && out.status === 200) {
|
|
84
|
+
void auto.recordError(model, { status: 200, slow: true, note: `stall ${out.detail.stallHits}x gap>${SCORE_STALL_MS}ms maxGap ${out.detail.maxGapMs}ms` });
|
|
85
|
+
void auto.recordLatency(model, latencyMs);
|
|
86
|
+
evt("slow-model", { model, elapsedMs: elapsed, threshold: SCORE_STALL_MS, reason: "stall", stallHits: out.detail.stallHits, maxGapMs: out.detail.maxGapMs, detail: out.detail ?? null });
|
|
87
|
+
scoredSlow = true;
|
|
88
|
+
}
|
|
89
|
+
if (!scoredSlow && auto && out.status === 200) await auto.recordOk(model, { latencyMs });
|
|
90
|
+
else if (!scoredSlow && auto) await auto.recordLatency(model, latencyMs);
|
|
91
|
+
evt("result", { model, status: out.status, via: "local", timing: upRes._t ?? null, ttfMs: out.ttfMs, totalMs: out.totalMs, detail: out.detail ?? null, fallback, requested, actual: model, hedged: true });
|
|
92
|
+
evt("client-response", { requested, actual: model, via: "local", fallback, status: out.status, reqId: handlerCtx.reqId });
|
|
93
|
+
if (plugins?.length) runHook(plugins, "request:completed", { reqId: handlerCtx.reqId, requested, useAuto, hops: handlerCtx.hops, stream: isStream, durationMs: Date.now() - startedAt, via: "local", status: out.status, actual: model, fallback }).catch(() => {});
|
|
94
|
+
return { handled: true };
|
|
95
|
+
} else if (hedged.winner === "peer" && hedged.peerInfo) {
|
|
96
|
+
const win = hedged.peerInfo;
|
|
97
|
+
evt("peer-race-win", { reqId: handlerCtx.reqId, model, winPeer: win.peer.url, winTarget: win.target, latencyMs: win.latencyMs, hedged: true, ttfMs: hedged.ttfMs });
|
|
98
|
+
await peers.recordResult(win.peer.url, { ok: true, latencyMs: win.latencyMs, model: win.target });
|
|
99
|
+
logCall(win.target, win.res.status);
|
|
100
|
+
const peerFallback = buildFallbackInfo({ requested, actual: win.target, lastErr, via: "peer", useAuto, lockModel });
|
|
101
|
+
if (peerFallback?.fallback) evt("fallback-notice", { reqId: handlerCtx.reqId, requested, actual: win.target, reason: peerFallback.reason, notice: peerFallback.notice, via: "peer" });
|
|
102
|
+
evt("relay-start", { reqId: handlerCtx.reqId, model: win.target, via: "peer", isStream, fallback: peerFallback, hedged: true });
|
|
103
|
+
const bufferedPeerRes = { ...win.res, body: hedged.bufferedBody, headers: win.res.headers, status: win.res.status, _t: win.res._t };
|
|
104
|
+
const out = await relay(res, bufferedPeerRes, body, {
|
|
105
|
+
fallback: peerFallback,
|
|
106
|
+
onFirstChunk: (d) => mark(`ttf-peer-${win.target}`),
|
|
107
|
+
onDownstreamAbort: () => evt("client-abort", { reqId: handlerCtx.reqId, model: win.target, totalMs: Math.round(perf0 ? (Date.now() - startedAt) : 0), stages: [...stages] }),
|
|
108
|
+
});
|
|
109
|
+
evt("relay-done", { reqId: handlerCtx.reqId, model: win.target, via: "peer", status: out.status, ttfMs: out.ttfMs ?? hedged.ttfMs, totalMs: out.totalMs, aborted: out.aborted, interrupted: out.interrupted ?? false, detail: out.detail ?? null, hedged: true });
|
|
110
|
+
if (auto && out.status === 200) {
|
|
111
|
+
const latencyMs = out.totalMs ?? win.latencyMs;
|
|
112
|
+
if (out.detail?.stallHits > 0 || (latencyMs && latencyMs > SLOW_TOTAL_MS)) {
|
|
113
|
+
void auto.recordError(win.target, { status: 200, slow: true, note: `peer slow ${latencyMs}ms` });
|
|
114
|
+
void auto.recordLatency(win.target, latencyMs);
|
|
115
|
+
} else {
|
|
116
|
+
await auto.recordOk(win.target, { latencyMs });
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
evt("result", { model: win.target, status: out.status, via: "peer", timing: win.res._t ?? null, ttfMs: out.ttfMs, totalMs: out.totalMs, detail: out.detail ?? null, fallback: peerFallback, requested, actual: win.target, hedged: true });
|
|
120
|
+
evt("client-response", { requested, actual: win.target, via: "peer", fallback: peerFallback, status: out.status, reqId: handlerCtx.reqId });
|
|
121
|
+
if (plugins?.length) runHook(plugins, "request:completed", { reqId: handlerCtx.reqId, requested, useAuto, hops: handlerCtx.hops, stream: isStream, durationMs: Date.now() - startedAt, via: "peer", status: out.status, actual: win.target, fallback: peerFallback }).catch(() => {});
|
|
122
|
+
return { handled: true };
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
if (hedged && hedged.needsPeer) {
|
|
126
|
+
return { handled: false, upRes: null, lastErr, needsPeer: true };
|
|
127
|
+
} else if (!hedged || !hedged.winner) {
|
|
128
|
+
evt("hedge-both-fail", { reqId: handlerCtx.reqId, model });
|
|
129
|
+
if (!hedged) {
|
|
130
|
+
if (auto) await auto.recordError(model, { status: 502, slow: false, note: "hedge both fail" });
|
|
131
|
+
const err = { model, upstream: null, status: 502, message: "hedge both failed" };
|
|
132
|
+
return { handled: false, upRes: null, lastErr: err };
|
|
133
|
+
}
|
|
134
|
+
return { handled: false, upRes: null, lastErr };
|
|
135
|
+
}
|
|
136
|
+
return { handled: false, upRes: null, lastErr };
|
|
137
|
+
} catch (hedgeErr) {
|
|
138
|
+
evt("hedge-error", { reqId: handlerCtx.reqId, model, error: String(hedgeErr?.message || hedgeErr).slice(0, 300) });
|
|
139
|
+
// 回退到串行
|
|
140
|
+
return { handled: false, upRes, lastErr };
|
|
141
|
+
}
|
|
142
|
+
}
|