mslxdff 0.1.109 → 0.1.111

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (35) hide show
  1. package/package.json +6 -1
  2. package/src/chat-pipeline/dedup.js +83 -0
  3. package/src/chat-pipeline/serial-trial.js +3 -3
  4. package/src/cli/commands/model/list.js +15 -2
  5. package/src/cli/commands/provider/models.js +14 -1
  6. package/src/cli/commands/sync.js +5 -0
  7. package/src/model-capabilities/enrich.js +86 -0
  8. package/src/model-capabilities/index.js +163 -0
  9. package/src/model-capabilities/parse.js +68 -0
  10. package/src/providers/base.js +11 -1
  11. package/src/providers/classify.js +22 -0
  12. package/src/providers/cline/chat.js +21 -6
  13. package/src/providers/model-id.js +29 -0
  14. package/src/providers/workbuddy/chat.js +19 -0
  15. package/src/providers/workbuddy/reshape.js +23 -16
  16. package/src/providers/workbuddy/sdk-chat.js +25 -0
  17. package/src/routes/hedge.js +2 -2
  18. package/src/routes/index.js +7 -1
  19. package/src/routes/models-route.js +54 -0
  20. package/src/runtime/providers-setup.js +2 -1
  21. package/src/state/schemas/use-group.js +16 -0
  22. package/src/sync-opencode.js +39 -12
  23. package/src/upstream-engine/index.js +69 -0
  24. package/src/upstream-engine/mode.js +13 -0
  25. package/src/upstream-engine/sdk/attempt.js +136 -0
  26. package/src/upstream-engine/sdk/chat.js +28 -0
  27. package/src/upstream-engine/sdk/convert.js +133 -0
  28. package/src/upstream-engine/sdk/dispatch.js +55 -0
  29. package/src/upstream-engine/sdk/responses.js +85 -0
  30. package/src/upstream-engine/sdk/sse.js +93 -0
  31. package/src/upstream-probe/display.js +52 -0
  32. package/src/upstream-probe/probe.js +49 -0
  33. package/src/upstream-probe/rotate.js +110 -0
  34. package/src/upstream-probe/start.js +45 -0
  35. package/src/upstream.js +23 -16
@@ -0,0 +1,133 @@
1
+ // OpenAI 请求体 → @ai-sdk/openai-compatible 入参(仅 chat)。
2
+ // 显式映射常用面;未映射字段经 toProviderExtras 原样透传(providerOptions.<providerName> 会被 spread 进请求体)。
3
+ // 共用库:workbuddy 实验通道与 upstream-engine 同源。见 .scratch/ai-sdk-upstream/SPEC.md。
4
+
5
+ const HANDLED_KEYS = new Set([
6
+ "model", "messages", "stream", "tools", "tool_choice",
7
+ "temperature", "top_p", "max_tokens", "stop",
8
+ "presence_penalty", "frequency_penalty", "seed", "reasoning_effort",
9
+ ]);
10
+
11
+ function textOf(content) {
12
+ if (typeof content === "string") return content;
13
+ if (Array.isArray(content)) {
14
+ return content.filter((p) => p?.type === "text" && typeof p.text === "string").map((p) => p.text).join("");
15
+ }
16
+ return "";
17
+ }
18
+
19
+ function userContentParts(content) {
20
+ if (typeof content === "string") return [{ type: "text", text: content }];
21
+ if (!Array.isArray(content)) return [{ type: "text", text: String(content ?? "") }];
22
+ const parts = [];
23
+ for (const p of content) {
24
+ if (!p || typeof p !== "object") continue;
25
+ if (p.type === "text" || p.type === "input_text") {
26
+ parts.push({ type: "text", text: String(p.text ?? "") });
27
+ } else if (p.type === "image_url") {
28
+ const url = typeof p.image_url === "string" ? p.image_url : p.image_url?.url;
29
+ if (!url) continue;
30
+ try { parts.push({ type: "file", mediaType: "image/*", data: new URL(url) }); } catch {}
31
+ }
32
+ }
33
+ if (!parts.length) parts.push({ type: "text", text: "" });
34
+ return parts;
35
+ }
36
+
37
+ function parseToolArgs(raw) {
38
+ if (raw && typeof raw === "object") return raw;
39
+ try { return JSON.parse(String(raw)); } catch { return {}; }
40
+ }
41
+
42
+ export function toModelPrompt(messages) {
43
+ const out = [];
44
+ for (const m of Array.isArray(messages) ? messages : []) {
45
+ if (!m || typeof m !== "object") continue;
46
+ const role = String(m.role || "");
47
+ if (role === "system" || role === "developer") {
48
+ out.push({ role: "system", content: textOf(m.content) });
49
+ } else if (role === "user") {
50
+ out.push({ role: "user", content: userContentParts(m.content) });
51
+ } else if (role === "assistant") {
52
+ const parts = [];
53
+ const text = textOf(m.content);
54
+ if (text) parts.push({ type: "text", text });
55
+ if (m.reasoning_content) parts.push({ type: "reasoning", text: String(m.reasoning_content) });
56
+ for (const tc of Array.isArray(m.tool_calls) ? m.tool_calls : []) {
57
+ if (!tc || typeof tc !== "object") continue;
58
+ parts.push({
59
+ type: "tool-call",
60
+ toolCallId: String(tc.id || ""),
61
+ toolName: String(tc.function?.name || ""),
62
+ input: parseToolArgs(tc.function?.arguments),
63
+ });
64
+ }
65
+ out.push({ role: "assistant", content: parts });
66
+ } else if (role === "tool") {
67
+ out.push({
68
+ role: "tool",
69
+ content: [{
70
+ type: "tool-result",
71
+ toolCallId: String(m.tool_call_id || ""),
72
+ toolName: String(m.name || "tool"),
73
+ output: { type: "text", value: typeof m.content === "string" ? m.content : JSON.stringify(m.content ?? "") },
74
+ }],
75
+ });
76
+ }
77
+ }
78
+ return out;
79
+ }
80
+
81
+ export function toModelTools(tools) {
82
+ if (!Array.isArray(tools)) return undefined;
83
+ const out = tools
84
+ .filter((t) => t && t.type === "function" && t.function?.name)
85
+ .map((t) => ({
86
+ type: "function",
87
+ name: String(t.function.name),
88
+ description: t.function.description ? String(t.function.description) : undefined,
89
+ inputSchema: t.function.parameters && typeof t.function.parameters === "object"
90
+ ? t.function.parameters
91
+ : { type: "object", properties: {} },
92
+ }));
93
+ return out.length ? out : undefined;
94
+ }
95
+
96
+ export function toModelToolChoice(choice) {
97
+ if (!choice) return undefined;
98
+ if (typeof choice === "string") {
99
+ return ["auto", "none", "required"].includes(choice) ? { type: choice } : undefined;
100
+ }
101
+ if (choice.type === "function" && choice.function?.name) {
102
+ return { type: "tool", toolName: String(choice.function.name) };
103
+ }
104
+ return undefined;
105
+ }
106
+
107
+ export function toProviderExtras(body) {
108
+ const extras = {};
109
+ for (const [k, v] of Object.entries(body || {})) {
110
+ if (!HANDLED_KEYS.has(k) && v !== undefined) extras[k] = v;
111
+ }
112
+ return Object.keys(extras).length ? extras : undefined;
113
+ }
114
+
115
+ export function toModelParams(body, providerName = "opencode") {
116
+ const params = {};
117
+ if (body?.max_tokens != null) params.maxOutputTokens = body.max_tokens;
118
+ if (body?.temperature != null) params.temperature = body.temperature;
119
+ if (body?.top_p != null) params.topP = body.top_p;
120
+ if (body?.presence_penalty != null) params.presencePenalty = body.presence_penalty;
121
+ if (body?.frequency_penalty != null) params.frequencyPenalty = body.frequency_penalty;
122
+ if (Array.isArray(body?.stop)) params.stopSequences = body.stop;
123
+ else if (typeof body?.stop === "string" && body.stop) params.stopSequences = [body.stop];
124
+ if (body?.seed != null) params.seed = body.seed;
125
+ const providerOptions = {};
126
+ if (body?.reasoning_effort != null) {
127
+ providerOptions.openaiCompatible = { reasoningEffort: String(body.reasoning_effort) };
128
+ }
129
+ const extras = toProviderExtras(body);
130
+ if (extras) providerOptions[providerName] = extras;
131
+ if (Object.keys(providerOptions).length) params.providerOptions = providerOptions;
132
+ return params;
133
+ }
@@ -0,0 +1,55 @@
1
+ // 供应商级 SDK 分派(薄壳):通用 OpenAI 兼容上游的"请求发出口"共用。
2
+ // 缺省 sdk(attemptOnceSdk 走 @ai-sdk/openai-compatible);varName 显式设 legacy/关闭词即回退,
3
+ // 未设置则继承全局 MSLXDFF_UPSTREAM_ENGINE(一键熔断)。
4
+ // SDK 不可用抛 _sdkLoadFailed → 回退原生并告警一次;其它错误 rethrow 交调用方重试语义。
5
+ // 见 .scratch/ai-sdk-providers-rest/SPEC.md 与 docs/adr/0017。
6
+ import { resolveEngineMode } from "../mode.js";
7
+ import { attemptOnceSdk } from "./attempt.js";
8
+
9
+ export function sdkEnvName(id) {
10
+ return `MSLXDFF_${String(id || "").replace(/[^A-Za-z0-9]+/g, "_").toUpperCase()}_SDK`;
11
+ }
12
+
13
+ export function createSdkDispatch({
14
+ id,
15
+ varName,
16
+ providerName,
17
+ markerName = "x-mslxdff-upstream-engine",
18
+ label,
19
+ env = process.env,
20
+ logger = console,
21
+ attemptImpl = attemptOnceSdk,
22
+ } = {}) {
23
+ const name = varName || sdkEnvName(id);
24
+ const provider = providerName || id || "provider";
25
+ const tag = label || provider;
26
+ const mode = resolveEngineMode(env, name);
27
+ let fallbackLogged = false;
28
+
29
+ return {
30
+ varName: name,
31
+ enabled: mode === "sdk",
32
+ // 成功返回 Response;_sdkLoadFailed → null(调用方回退原生);其它错误 rethrow
33
+ async trySdk({ url, body, headers, fetchImpl, clock } = {}) {
34
+ try {
35
+ return await attemptImpl({
36
+ url,
37
+ body,
38
+ headers,
39
+ providerName: provider,
40
+ marker: markerName ? { name: markerName, value: "sdk" } : null,
41
+ fetchImpl,
42
+ clock,
43
+ });
44
+ } catch (e) {
45
+ if (e && e._sdkUnsupported) return null; // 异形 chatPath:静默回退原生
46
+ if (!e || !e._sdkLoadFailed) throw e;
47
+ if (!fallbackLogged) {
48
+ fallbackLogged = true;
49
+ try { logger.error(`[${tag}] ${e.message} — 回退原生通道`); } catch {}
50
+ }
51
+ return null;
52
+ }
53
+ },
54
+ };
55
+ }
@@ -0,0 +1,85 @@
1
+ // Responses API 适配器(@ai-sdk/openai):models.dev 里模型级 npm=@ai-sdk/openai 的模型
2
+ // (如 muse-spark*,打 /zen/v1/responses)。与 chat 适配器同契约:输出 OpenAI chat SSE 帧。
3
+ // 复用 attempt.js 的 baseURL 解析 / 错误映射 / 流序列化,不复制实现。
4
+ // 见 .scratch/ai-sdk-upstream/SPEC-p3-responses.md 与 docs/adr/0017。
5
+ import { toModelPrompt, toModelTools, toModelToolChoice, toModelParams } from "./convert.js";
6
+ import { sdkBaseFromUrl, sanitizeHeaders, errorResponseFromSdkError, streamResponseFromParts } from "./attempt.js";
7
+ import { ENGINE_MARKER } from "./chat.js";
8
+
9
+ export const RESPONSES_CHAT_PATH = "/zen/v1/responses";
10
+
11
+ let sdkPromise = null;
12
+
13
+ export function loadOpenAISdk() {
14
+ if (!sdkPromise) sdkPromise = import("@ai-sdk/openai");
15
+ return sdkPromise;
16
+ }
17
+
18
+ export async function attemptOnceResponsesSdk({
19
+ url,
20
+ body,
21
+ headers = {},
22
+ providerName = "opencode",
23
+ marker = null,
24
+ clock = Date.now,
25
+ sdkLoader = loadOpenAISdk,
26
+ fetchImpl,
27
+ } = {}) {
28
+ const t0 = clock();
29
+ const baseURL = sdkBaseFromUrl(url, "/responses");
30
+ if (!baseURL) throw new Error(`sdk-channel: responsesPath 非 /responses,暂不支持(${url})`);
31
+ let createOpenAI;
32
+ try {
33
+ ({ createOpenAI } = await sdkLoader());
34
+ } catch (e) {
35
+ const err = new Error(`sdk-channel: @ai-sdk/openai 不可用(需 Node>=18 且已安装): ${e?.message || e}`);
36
+ err._sdkLoadFailed = true;
37
+ throw err;
38
+ }
39
+ // headers 由调用方构造(含 Authorization),headers 优先于 apiKey 默认头
40
+ const provider = createOpenAI({
41
+ name: providerName,
42
+ baseURL,
43
+ apiKey: "public",
44
+ headers: sanitizeHeaders(headers),
45
+ ...(fetchImpl ? { fetch: fetchImpl } : {}),
46
+ });
47
+ const model = provider.responses(String(body?.model || ""));
48
+ let res;
49
+ try {
50
+ res = await model.doStream({
51
+ prompt: toModelPrompt(body?.messages),
52
+ ...toModelParams(body, providerName),
53
+ tools: toModelTools(body?.tools),
54
+ toolChoice: toModelToolChoice(body?.tool_choice),
55
+ });
56
+ } catch (e) {
57
+ const mapped = errorResponseFromSdkError(e, { marker });
58
+ if (mapped) return mapped;
59
+ throw e;
60
+ }
61
+ return streamResponseFromParts(res.stream, { marker, clock, t0 });
62
+ }
63
+
64
+ export function createSdkResponses({
65
+ baseUrl,
66
+ chatPath = RESPONSES_CHAT_PATH,
67
+ buildHeaders,
68
+ providerName = "opencode",
69
+ marker = ENGINE_MARKER,
70
+ fetchImpl,
71
+ } = {}) {
72
+ const base = String(baseUrl || "").replace(/\/+$/, "");
73
+ return {
74
+ async chat(body) {
75
+ return attemptOnceResponsesSdk({
76
+ url: `${base}${chatPath}`,
77
+ body,
78
+ headers: buildHeaders ? buildHeaders(body) : {},
79
+ providerName,
80
+ marker,
81
+ fetchImpl,
82
+ });
83
+ },
84
+ };
85
+ }
@@ -0,0 +1,93 @@
1
+ // AI SDK parts → OpenAI SSE 帧(供 reshape/转发链按原生 workbuddy 帧同等处理)。
2
+ // 纯函数序列化器:push(part) 返回该 part 产生的 SSE 文本(可能为空串/组合帧),end() 返回 [DONE]。
3
+ // 见 .scratch/workbuddy-sdk-channel/SPEC.md。
4
+
5
+ export function usageToOpenAI(u) {
6
+ if (!u || typeof u !== "object") return undefined;
7
+ if (u.raw && typeof u.raw === "object") return u.raw;
8
+ const prompt = u.inputTokens?.total ?? 0;
9
+ const completion = u.outputTokens?.total ?? 0;
10
+ return { prompt_tokens: prompt, completion_tokens: completion, total_tokens: prompt + completion };
11
+ }
12
+
13
+ export function createSseSerializer() {
14
+ const meta = { id: "chatcmpl-wb-sdk", model: "", created: Math.floor(Date.now() / 1000) };
15
+ let roleSent = false;
16
+ let nextIndex = 0;
17
+ const toolIndex = new Map();
18
+ const toolDeltaIds = new Set();
19
+
20
+ function frame(delta, { finishReason = null, usage } = {}) {
21
+ const obj = {
22
+ id: meta.id,
23
+ object: "chat.completion.chunk",
24
+ created: meta.created,
25
+ model: meta.model,
26
+ choices: [{ index: 0, delta, finish_reason: finishReason }],
27
+ };
28
+ if (usage !== undefined) obj.usage = usage;
29
+ return `data: ${JSON.stringify(obj)}\n\n`;
30
+ }
31
+
32
+ function ensureRole() {
33
+ if (roleSent) return "";
34
+ roleSent = true;
35
+ return frame({ role: "assistant" });
36
+ }
37
+
38
+ function push(part) {
39
+ if (!part || typeof part !== "object") return null;
40
+ switch (part.type) {
41
+ case "response-metadata": {
42
+ if (part.id) meta.id = String(part.id);
43
+ if (part.modelId) meta.model = String(part.modelId);
44
+ if (part.timestamp) {
45
+ const t = new Date(part.timestamp).getTime();
46
+ if (Number.isFinite(t)) meta.created = Math.floor(t / 1000);
47
+ }
48
+ return null;
49
+ }
50
+ case "reasoning-delta":
51
+ return ensureRole() + frame({ reasoning_content: String(part.delta ?? "") });
52
+ case "text-delta":
53
+ return ensureRole() + frame({ content: String(part.delta ?? "") });
54
+ case "tool-input-start": {
55
+ const idx = nextIndex++;
56
+ toolIndex.set(part.id, idx);
57
+ toolDeltaIds.add(part.id);
58
+ return ensureRole() + frame({
59
+ tool_calls: [{ index: idx, id: part.id, type: "function", function: { name: part.toolName || "", arguments: "" } }],
60
+ });
61
+ }
62
+ case "tool-input-delta": {
63
+ const idx = toolIndex.has(part.id) ? toolIndex.get(part.id) : 0;
64
+ return frame({ tool_calls: [{ index: idx, function: { arguments: String(part.delta ?? "") } }] });
65
+ }
66
+ case "tool-call": {
67
+ const id = part.toolCallId;
68
+ const idx = toolIndex.has(id) ? toolIndex.get(id) : nextIndex++;
69
+ toolIndex.set(id, idx);
70
+ if (toolDeltaIds.has(id)) return null;
71
+ const args = typeof part.input === "string" ? part.input : JSON.stringify(part.input ?? {});
72
+ return ensureRole() + frame({ tool_calls: [{ index: idx, id, type: "function", function: { name: part.toolName || "", arguments: args } }] });
73
+ }
74
+ case "finish": {
75
+ const reason = part.finishReason?.raw ?? part.finishReason?.unified ?? "stop";
76
+ return frame({}, { finishReason: reason, usage: usageToOpenAI(part.usage) });
77
+ }
78
+ case "error": {
79
+ const message = part.error?.message
80
+ || (typeof part.error === "string" ? part.error : JSON.stringify(part.error ?? "unknown error"));
81
+ return `data: ${JSON.stringify({ error: { message } })}\n\n`;
82
+ }
83
+ default:
84
+ return null;
85
+ }
86
+ }
87
+
88
+ function end() {
89
+ return "data: [DONE]\n\n";
90
+ }
91
+
92
+ return { push, end };
93
+ }
@@ -0,0 +1,52 @@
1
+ // ADR-0015 展示渲染:-group list 行缀 + -status 汇总(纯函数,输入 routes 数据)
2
+ // 四态文案:无数据→引导;有数据→摘要;坏数据→标 offline
3
+
4
+ function bestDisplay(entry) {
5
+ if (!entry) return null;
6
+ const best = String(entry.best || "direct");
7
+ const ms = best === "direct" ? (entry.direct?.ttfbMs ?? entry.direct?.totalMs) : (entry.via?.[best.slice(4)]?.ttfbMs ?? entry.via?.[best.slice(4)]?.totalMs);
8
+ const tag = best === "direct" ? "direct" : `via ${best.slice(4)}`;
9
+ if (entry.direct?.ok === false && !best.startsWith("via:")) return `${tag} (${entry.direct?.label || "offline"})`;
10
+ return ms != null ? `${tag} ${ms}ms` : tag;
11
+ }
12
+
13
+ // -group list 静态成员行尾缀:该成员作为 best 出现时的供应商与相对直连收益
14
+ export function groupRowSuffix(peerUrl, routes) {
15
+ if (!peerUrl || !routes || typeof routes !== "object") return null;
16
+ const label = (() => {
17
+ const raw = String(peerUrl);
18
+ if (raw.includes("://")) {
19
+ try { const u = new URL(raw); return `${u.hostname}${u.port ? `:${u.port}` : ""}`; } catch { return raw.slice(-16); }
20
+ }
21
+ return raw;
22
+ })();
23
+ const wins = [];
24
+ for (const [key, entry] of Object.entries(routes)) {
25
+ if (!key.startsWith("provider:")) continue;
26
+ const prov = key.slice(9);
27
+ if (entry?.best === `via:${label}`) {
28
+ const d = entry.deltaMs;
29
+ wins.push(d != null ? `${prov} ${d > 0 ? "+" : ""}${d}ms` : prov);
30
+ }
31
+ }
32
+ if (!wins.length) return null;
33
+ return ` via-routes: ${wins.join(", ")}`;
34
+ }
35
+
36
+ // -status 汇总(多行字符串):探针条目摘要 ≤5 条;空数据给引导
37
+ export function statusViaSummary({ routes, meta, probeMs }) {
38
+ const list = Object.entries(routes || {}).filter(([k]) => k.startsWith("provider:"));
39
+ if (!list.length) {
40
+ return `via-routes: 无探针数据(后台探针 ${probeMs === 0 ? "已关闭" : "自动运行中"},加入组并配置 key/token 类供应商后自动生成)`;
41
+ }
42
+ const at = meta?.at ? new Date(meta.at) : null;
43
+ const atStr = at && !Number.isNaN(at.getTime()) ? at.toISOString().slice(11, 19) + "Z" : "";
44
+ const lines = [`via-routes: ${list.length} 条(探针自动${atStr ? `,${atStr} 刷新` : ""})`];
45
+ for (const [key, entry] of list.slice(0, 5)) {
46
+ const prov = key.slice(9);
47
+ const b = bestDisplay(entry);
48
+ if (b) lines.push(` ${prov} → ${b}${entry.deltaMs != null ? ` (${entry.deltaMs > 0 ? "+" : ""}${entry.deltaMs}ms vs direct)` : ""}`);
49
+ }
50
+ if (list.length > 5) lines.push(` … 其余 ${list.length - 5} 条见 via-routes.json`);
51
+ return lines.join("\n");
52
+ }
@@ -0,0 +1,49 @@
1
+ // ADR-0015 探针薄层:direct GET 探针 + 组员 /v1/relay 代发探针
2
+ // 只测 GET <modelsPath>,不烧 token;组长侧计时。
3
+ import { errMsg } from "../cli/util.js";
4
+ import { compatFetch, timeoutSignal } from "../compat.js";
5
+
6
+ export const PROBE_TIMEOUT_MS = 5000;
7
+
8
+ export async function directProbe({ baseUrl, modelsPath = "/models", key = "", timeoutMs = PROBE_TIMEOUT_MS, fetchImpl = compatFetch, clock = Date.now, sampleMs } = {}) {
9
+ const base = String(baseUrl || "").replace(/\/+$/, "");
10
+ if (!base) return { ok: false, label: "配置错误", error: "missing baseUrl", ttfbMs: null };
11
+ const p = String(modelsPath || "/models");
12
+ const url = `${base}${p.startsWith("/") ? p : `/${p}`}`;
13
+ const t0 = sampleMs ? sampleMs() : clock();
14
+ try {
15
+ const headers = { Accept: "application/json" };
16
+ if (key) headers.Authorization = `Bearer ${key}`;
17
+ const res = await fetchImpl(url, { method: "GET", headers, signal: timeoutSignal(timeoutMs) });
18
+ const ms = sampleMs ? sampleMs() - t0 : clock() - t0;
19
+ const status = typeof res.status === "number" ? res.status : (res.ok ? 200 : 0);
20
+ if (status >= 400) return { ok: false, label: `HTTP ${status}`, ttfbMs: null };
21
+ return { ok: true, ttfbMs: Math.max(0, Math.round(ms)) };
22
+ } catch (e) {
23
+ return { ok: false, label: errMsg(e).slice(0, 60), ttfbMs: null };
24
+ }
25
+ }
26
+
27
+ // 组员代发:POST <peer>/v1/relay { targetUrl, method:"GET", headers } → 组员 fetch 上游后原样回
28
+ export async function relayViaProbe({ peerUrl, peerToken = "", targetUrl, authHeader = "", timeoutMs = PROBE_TIMEOUT_MS, fetchImpl = compatFetch, clock = Date.now, sampleMs } = {}) {
29
+ const peer = String(peerUrl || "").replace(/\/+$/, "");
30
+ if (!peer || !targetUrl) return { ok: false, label: "配置错误", ttfbMs: null };
31
+ const t0 = sampleMs ? sampleMs() : clock();
32
+ try {
33
+ const headers = { "Content-Type": "application/json", Accept: "application/json" };
34
+ if (peerToken) headers.Authorization = `Bearer ${peerToken}`;
35
+ const res = await fetchImpl(`${peer}/v1/relay`, {
36
+ method: "POST",
37
+ headers,
38
+ body: JSON.stringify({ targetUrl, method: "GET", headers: authHeader ? { Authorization: authHeader } : {} }),
39
+ signal: timeoutSignal(timeoutMs),
40
+ });
41
+ const ms = sampleMs ? sampleMs() - t0 : clock() - t0;
42
+ const relayStatus = res.headers?.get?.("x-mslxdff-relay-status");
43
+ const status = relayStatus != null ? Number(relayStatus) : (res.status ?? 0);
44
+ if (status >= 400) return { ok: false, label: `HTTP ${status}`, ttfbMs: null };
45
+ return { ok: true, ttfbMs: Math.max(0, Math.round(ms)) };
46
+ } catch (e) {
47
+ return { ok: false, label: errMsg(e).slice(0, 60), ttfbMs: null };
48
+ }
49
+ }
@@ -0,0 +1,110 @@
1
+ // ADR-0015 轮转调度:每 tick 探 1 家供应商(direct + 逐 peer relay),EMA 合并旧值后落 via-routes.json
2
+ // 复用 bench/via-routes.js 的表格式与读写;探针写 provider:<id> 键,getViaRoute 精确键优先、provider 级回退。
3
+ import { directProbe, relayViaProbe } from "./probe.js";
4
+ import { classifyProvider, emaMerge } from "../providers/classify.js";
5
+ import { saveViaRoutes, loadViaRoutes } from "../bench/via-routes.js";
6
+ import { errMsg } from "../cli/util.js";
7
+
8
+ export const PROBE_DELAY_MS = Number(process.env.MSLXDFF_BENCH_DELAY_MS || 120) || 0;
9
+
10
+ // 从 providerConfigs 收集探针目标:local-only(workbuddy)/quota-pool(opencode) 排除,无 baseUrl 跳过
11
+ export function probeTargetsFromState({ loadProviderConfigs, loadProviderKeys, loadProviderBaseUrl } = {}) {
12
+ const out = [];
13
+ const ids = new Set(Object.keys(loadProviderConfigs?.() || {}));
14
+ for (const id of ids) {
15
+ if (classifyProvider(id) !== "latency-compare") continue;
16
+ const cfg = (loadProviderConfigs?.() || {})[id] || {};
17
+ const baseUrl = String(loadProviderBaseUrl?.(id) || cfg.baseUrl || "").replace(/\/+$/, "");
18
+ if (!baseUrl) continue;
19
+ out.push({ id, baseUrl, modelsPath: cfg.modelsPath || "/models", key: (loadProviderKeys?.(id) || [])[0] || "" });
20
+ }
21
+ return out;
22
+ }
23
+
24
+ export function nextCursor(cursor, total) {
25
+ if (!Number.isInteger(total) || total <= 0) return 0;
26
+ const c = Number.isInteger(cursor) ? cursor : 0;
27
+ return c + 1 >= total ? 0 : c + 1;
28
+ }
29
+
30
+ function peerLabel(p) {
31
+ const raw = String(p?.url || "");
32
+ if (!raw) return "";
33
+ if (raw.includes("://")) {
34
+ try { const u = new URL(raw); return `${u.hostname}${u.port ? `:${u.port}` : ""}`; } catch { return raw.slice(-16); }
35
+ }
36
+ return raw;
37
+ }
38
+
39
+ // 单 tick:探 targets[cursor] 一家;返回 { probed, skipped }
40
+ export async function rotateTick({
41
+ targets,
42
+ cursor = 0,
43
+ peers,
44
+ timeoutMs = 5000,
45
+ delayMs = PROBE_DELAY_MS,
46
+ evt = () => {},
47
+ fetchImpl,
48
+ clock = Date.now,
49
+ sampleMs,
50
+ file,
51
+ } = {}) {
52
+ const list = Array.isArray(targets) ? targets : [];
53
+ if (!list.length) return { probed: null, skipped: true };
54
+ const idx = cursor % list.length;
55
+ const target = list[idx];
56
+ const peerList = (peers?.all?.() || []).filter((p) => p?.url && !String(p.url).startsWith("relay://"));
57
+ if (!peerList.length) {
58
+ evt("upstream-probe-skip", { provider: target.id, reason: "no peers" });
59
+ return { probed: null, skipped: true };
60
+ }
61
+ const authHeader = target.key ? `Bearer ${target.key}` : "";
62
+ const direct = await directProbe({ baseUrl: target.baseUrl, modelsPath: target.modelsPath, key: target.key, timeoutMs, fetchImpl, clock, sampleMs });
63
+ const via = {};
64
+ for (const p of peerList) {
65
+ const label = peerLabel(p) || String(p.url);
66
+ const r = await relayViaProbe({ peerUrl: p.url, peerToken: p.token || "", targetUrl: `${target.baseUrl}${target.modelsPath}`, authHeader, timeoutMs, fetchImpl, clock, sampleMs });
67
+ via[label] = { ok: r.ok, ttfbMs: r.ok ? r.ttfbMs : null, totalMs: r.ok ? r.ttfbMs : null, label: r.ok ? null : r.label };
68
+ if (delayMs) await new Promise((res) => setTimeout(res, delayMs));
69
+ }
70
+ const prev = loadViaRoutes(file).routes?.[`provider:${target.id}`] || null;
71
+ const fresh = prev?.at && Date.now() - Date.parse(prev.at) < 10 * 60_000 ? prev : null;
72
+ const merged = mergeEntry({ prev: fresh, direct, via });
73
+ const results = [{ model: `provider:${target.id}`, ...merged, provider: target.id }];
74
+ try {
75
+ saveViaRoutes(results, { file, meta: { probe: true } });
76
+ } catch (e) {
77
+ evt("upstream-probe-error", { provider: target.id, error: errMsg(e).slice(0, 120) });
78
+ return { probed: target.id, skipped: false, error: errMsg(e) };
79
+ }
80
+ evt("upstream-probe", { provider: target.id, direct: direct.ok ? direct.ttfbMs : null, best: merged.best, peers: Object.keys(via).length });
81
+ return { probed: target.id, skipped: false };
82
+ }
83
+
84
+ // best 计算 + EMA 合并(相对 direct:via 胜出记负 delta)
85
+ function mergeEntry({ prev, direct, via }) {
86
+ const ema = (p, r) => emaMerge(p, r?.ok ? r.ttfbMs : null);
87
+ const directOut = {
88
+ ok: Boolean(direct?.ok),
89
+ ttfbMs: direct?.ok ? ema(prev?.direct?.ttfbMs, direct) : null,
90
+ totalMs: direct?.ok ? ema(prev?.direct?.totalMs ?? prev?.direct?.ttfbMs, direct) : null,
91
+ label: direct?.ok ? null : (direct?.label || "offline"),
92
+ error: direct?.ok ? null : (direct?.label || null),
93
+ };
94
+ const viaOut = {};
95
+ for (const [k, v] of Object.entries(via || {})) {
96
+ viaOut[k] = v.ok
97
+ ? { ok: true, ttfbMs: ema(prev?.via?.[k]?.ttfbMs, v), totalMs: ema(prev?.via?.[k]?.totalMs ?? prev?.via?.[k]?.ttfbMs, v) }
98
+ : { ok: false, ttfbMs: v.ttfbMs ?? null, totalMs: v.totalMs ?? null, label: v.label || "offline" };
99
+ }
100
+ const directMs = directOut.ok ? (directOut.ttfbMs ?? directOut.totalMs) : Infinity;
101
+ let best = "direct";
102
+ let bestMs = directMs;
103
+ for (const [k, v] of Object.entries(viaOut)) {
104
+ if (!v.ok) continue;
105
+ const t = v.ttfbMs ?? v.totalMs;
106
+ if (Number.isFinite(t) && t < bestMs) { bestMs = t; best = `via:${k}`; }
107
+ }
108
+ const deltaMs = best.startsWith("via:") && Number.isFinite(directMs) ? Math.round(bestMs - directMs) : null;
109
+ return { best, direct: directOut, via: viaOut, deltaMs };
110
+ }
@@ -0,0 +1,45 @@
1
+ // ADR-0015 daemon 装配:60s 节拍轮转探针;0=完全关闭
2
+ import { probeTargetsFromState, rotateTick, nextCursor } from "./rotate.js";
3
+ import { loadProviderConfigs, loadProviderKeys, loadProviderBaseUrl } from "../state.js";
4
+ import { loadPeers } from "../state.js";
5
+
6
+ export function upstreamProbeIntervalMs() {
7
+ const n = Number(process.env.MSLXDFF_UPSTREAM_PROBE_MS);
8
+ return Number.isInteger(n) && n >= 0 ? n : 60_000;
9
+ }
10
+
11
+ export function startUpstreamProbe({ evt = () => {}, peers, file, delayMs } = {}) {
12
+ const interval = upstreamProbeIntervalMs();
13
+ if (interval === 0) return null;
14
+ let cursor = 0;
15
+ let busy = false;
16
+ const loadTargets = () => {
17
+ try {
18
+ return probeTargetsFromState({
19
+ loadProviderConfigs: () => loadProviderConfigs({}),
20
+ loadProviderKeys: (id) => loadProviderKeys(id, {}),
21
+ loadProviderBaseUrl: (id) => loadProviderBaseUrl(id, {}),
22
+ });
23
+ } catch {
24
+ return [];
25
+ }
26
+ };
27
+ const tick = async () => {
28
+ if (busy) return;
29
+ busy = true;
30
+ try {
31
+ const targets = loadTargets();
32
+ if (!targets.length) return;
33
+ const out = await rotateTick({ targets, cursor, peers, evt, file, delayMs });
34
+ if (out.probed != null) cursor = nextCursor(cursor, targets.length);
35
+ } catch {
36
+ // 探针永不影响主链路
37
+ } finally {
38
+ busy = false;
39
+ }
40
+ };
41
+ void tick();
42
+ const timer = setInterval(tick, interval);
43
+ timer.unref?.();
44
+ return { stop: () => clearInterval(timer), interval, tick };
45
+ }