mslxdff 0.1.236 → 0.1.237

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 (102) hide show
  1. package/bin/mslxdff.js +3 -3
  2. package/package.json +1 -1
  3. package/src/auto.js +264 -264
  4. package/src/autostart.js +62 -15
  5. package/src/bench/cline-bench.js +42 -42
  6. package/src/bench/probe.js +70 -70
  7. package/src/bench/report.js +162 -162
  8. package/src/bench/runner.js +77 -77
  9. package/src/bench/via-probe.js +124 -124
  10. package/src/bench/via-routes.js +87 -87
  11. package/src/bench/workbuddy-bench.js +54 -54
  12. package/src/chat/engine.js +160 -160
  13. package/src/chat/gateway.js +163 -163
  14. package/src/chat/orchestrator.js +234 -234
  15. package/src/chat/prompt.js +70 -70
  16. package/src/chat/repl.js +88 -88
  17. package/src/chat/terminal.js +135 -135
  18. package/src/chat/tools.js +306 -306
  19. package/src/chat-pipeline/index.js +155 -155
  20. package/src/chat-pipeline/policy.js +84 -84
  21. package/src/cli/commands/group.js +266 -266
  22. package/src/cli/commands/model/picks.js +50 -50
  23. package/src/cli/commands/provider/bench-via.js +247 -247
  24. package/src/cli/commands/provider/bench.js +141 -141
  25. package/src/cli/commands/provider/index.js +139 -139
  26. package/src/cli/commands/provider/models.js +149 -149
  27. package/src/cli/commands/provider/qwenwork-login.js +119 -119
  28. package/src/cli/commands/provider/raccoon-quota.js +156 -156
  29. package/src/cli/commands/sync.js +310 -310
  30. package/src/cli/status.js +282 -282
  31. package/src/daemon.js +246 -246
  32. package/src/model-capabilities/enrich.js +86 -86
  33. package/src/model-capabilities/index.js +183 -183
  34. package/src/model-capabilities/parse.js +70 -70
  35. package/src/models.js +293 -293
  36. package/src/peers.js +389 -389
  37. package/src/providers/agentrouter.js +238 -238
  38. package/src/providers/base.js +164 -164
  39. package/src/providers/cline/auth.js +228 -228
  40. package/src/providers/cline/chat.js +307 -307
  41. package/src/providers/cline.js +2 -2
  42. package/src/providers/codearts/chat.js +207 -207
  43. package/src/providers/dispatcher.js +147 -147
  44. package/src/providers/globalqwenwork/cosy.js +123 -123
  45. package/src/providers/globalqwenwork/crypto.js +218 -218
  46. package/src/providers/globalqwenwork/http.js +20 -20
  47. package/src/providers/globalqwenwork/rsa.js +54 -54
  48. package/src/providers/globalqwenwork/sse.js +268 -268
  49. package/src/providers/globalqwenwork/stream.js +130 -130
  50. package/src/providers/globalqwenwork/upstream.js +122 -122
  51. package/src/providers/keyring.js +60 -60
  52. package/src/providers/qoder/chat.js +292 -292
  53. package/src/providers/qoder/index.js +311 -311
  54. package/src/providers/qoder/sse.js +233 -233
  55. package/src/providers/qwenwork/account-store.js +133 -133
  56. package/src/providers/qwenwork/constants.js +67 -67
  57. package/src/providers/qwenwork/cosy.js +120 -120
  58. package/src/providers/qwenwork/crypto.js +218 -218
  59. package/src/providers/qwenwork/http.js +20 -20
  60. package/src/providers/qwenwork/index.js +337 -337
  61. package/src/providers/qwenwork/payload.js +144 -144
  62. package/src/providers/qwenwork/rsa.js +54 -54
  63. package/src/providers/qwenwork/sse.js +268 -268
  64. package/src/providers/qwenwork/stream.js +130 -130
  65. package/src/providers/qwenwork/upstream.js +120 -120
  66. package/src/providers/qwenwork.js +1 -1
  67. package/src/providers/raccoon/const.js +219 -219
  68. package/src/providers/raccoon/credits.js +285 -285
  69. package/src/providers/registry.js +89 -89
  70. package/src/providers/workbuddy/chat.js +298 -298
  71. package/src/providers/workbuddy/reshape.js +152 -152
  72. package/src/providers/workbuddy.js +2 -2
  73. package/src/reasoning.js +32 -32
  74. package/src/routes/chat/broadband-handler.js +80 -80
  75. package/src/routes/chat/gateway.js +46 -46
  76. package/src/routes/chat/relay-pipeline.js +282 -282
  77. package/src/routes/chat/via-route-handler.js +158 -158
  78. package/src/routes/hedge.js +260 -260
  79. package/src/routes/models-route.js +167 -167
  80. package/src/routes/peers.js +373 -373
  81. package/src/routes/stream.js +364 -364
  82. package/src/runtime/bootstrap.js +75 -75
  83. package/src/runtime/keep-warm.js +135 -135
  84. package/src/runtime/provider-gate.js +75 -75
  85. package/src/runtime/providers-setup.js +187 -187
  86. package/src/runtime/raccoon-checkin.js +175 -175
  87. package/src/runtime/server-lifecycle.js +34 -10
  88. package/src/server.js +85 -85
  89. package/src/state/schemas/allowlist.js +92 -92
  90. package/src/sync-opencode.js +280 -280
  91. package/src/transport/index.js +244 -244
  92. package/src/transport/pool.js +56 -56
  93. package/src/transport/retry.js +24 -24
  94. package/src/transport/sse.js +93 -93
  95. package/src/upstream-probe/display.js +52 -52
  96. package/src/upstream-probe/probe.js +49 -49
  97. package/src/upstream-probe/rotate.js +110 -110
  98. package/src/upstream-probe/start.js +45 -45
  99. package/src/upstream.js +355 -355
  100. package/docs/cli_help_mini.md +0 -149
  101. package/src/providers/AGENTS.md +0 -49
  102. package/src/routes/AGENTS.md +0 -37
@@ -1,307 +1,307 @@
1
- import { joinUrl, sleep } from "../base.js";
2
- import { appendEvent } from "../../logs.js";
3
- import { createHash } from "node:crypto";
4
- import { clineHeaders } from "./headers.js";
5
- import { createTransport } from "../../transport/index.js";
6
- import { normalizeProviderId } from "../model-id.js";
7
- import { recordLimit, recordOutput, exactOutputTokens } from "./usage.js";
8
- import { createSdkDispatch } from "../../upstream-engine/sdk/dispatch.js";
9
-
10
- function genSessionId() { return `sess_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`; }
11
-
12
- // 只记哈希标识,不落邮箱 / refreshToken(events.log 可读不可泄)
13
- function acctId(account) {
14
- const seed = String(account?.refreshToken || "");
15
- if (!seed) return "slot-unknown";
16
- return `acct_${createHash("sha256").update(seed).digest("hex").slice(0, 8)}`;
17
- }
18
- // 池子快照:ready/cooling 按「该模型」口径统计(ready=对该模型可用的号数)。
19
- // isReady 由调用方注入 authPool.accountAvailable(a, model),避免与 auth 逻辑分叉。
20
- function poolStat(pool, isReady = () => true) {
21
- let cooling = 0;
22
- let dead = 0;
23
- for (const a of pool || []) {
24
- if (a.dead) dead++;
25
- else if (!isReady(a)) cooling++;
26
- }
27
- const total = (pool || []).length;
28
- return { total, cooling, dead, ready: total - cooling - dead };
29
- }
30
-
31
- // 429 错误体自带权威模型名("...Daily free limit reached on model meta/muse-spark-1.3-contributor")。
32
- function limitModelOf(bodyText) {
33
- // 模型名可含 / . - _ : 字母数字(如 meta/muse-spark-1.3-contributor),
34
- // 遇空格/引号/逗号/句号结尾或 "Try again" 前的空白即停。
35
- const m = String(bodyText || "").match(/on model\s+([A-Za-z0-9/._:-]+?)(?=[\s"',]|\.\s|$)/i);
36
- return m ? m[1] : "";
37
- }
38
-
39
- // stripProviderPrefix 只剥「本供应商」前缀(cline/<裸 id> → 裸 id),非本前缀(含上游自带多段 id
40
- // 如 meta/vendor/x)原样透传,不误削首段(旧实现按段数剥曾是潜在 bug)。
41
- function stripProviderPrefix(model, providerId) {
42
- const s = String(model || "");
43
- const i = s.indexOf("/");
44
- if (i <= 0) return s;
45
- const low = (v) => normalizeProviderId(String(v || "").toLowerCase());
46
- return low(s.slice(0, i)) === low(providerId) ? s.slice(i + 1) : s;
47
- }
48
- function unwrapData(obj) {
49
- if (obj && obj.data && typeof obj.data === "object") {
50
- const d = obj.data;
51
- if (d.choices || d.id || d.usage || d.output) return d;
52
- }
53
- return obj;
54
- }
55
-
56
- async function streamToNonStream(upstream, track = null) {
57
- let content = "";
58
- let reasoning = "";
59
- let finishReason = null;
60
- let model = "";
61
- let id = "";
62
- let usage = null;
63
- for await (const ev of upstream.stream()) {
64
- if (!ev || ev === "[DONE]") continue;
65
- try {
66
- const obj = JSON.parse(ev);
67
- const normalized = unwrapData(obj);
68
- const choice = normalized?.choices?.[0];
69
- if (!choice) continue;
70
- const delta = choice.delta || {};
71
- if (delta.content) content += delta.content;
72
- if (delta.reasoning) reasoning += delta.reasoning;
73
- if (choice.finish_reason) finishReason = choice.finish_reason;
74
- if (normalized.id) id = normalized.id;
75
- if (normalized.model) model = normalized.model;
76
- if (normalized.usage) usage = normalized.usage;
77
- } catch {}
78
- }
79
- const msg = { role: "assistant", content };
80
- if (reasoning) msg.reasoning = reasoning;
81
- if (!content && reasoning) { msg.content = reasoning; msg.reasoning_used_as_content = true; }
82
- if (track) {
83
- const exact = exactOutputTokens(usage);
84
- if (exact) track({ tokens: exact, estimated: false });
85
- else if (content || reasoning) track({ tokens: Math.ceil((content.length + reasoning.length) / 4), estimated: true });
86
- }
87
- return {
88
- id: id || `gen_${Date.now()}`,
89
- object: "chat.completion",
90
- created: Math.floor(Date.now() / 1000),
91
- model: model || "",
92
- choices: [{ index: 0, message: msg, finish_reason: finishReason || "stop", logprobs: null, native_finish_reason: finishReason || "stop" }],
93
- usage: usage || { prompt_tokens: 0, completion_tokens: 0, total_tokens: 0 },
94
- };
95
- }
96
-
97
- export function createChatService({
98
- id,
99
- baseUrl,
100
- chatPath,
101
- fetchImpl,
102
- dispatcher,
103
- authPool,
104
- connectTimeoutMs = 30_000,
105
- } = {}) {
106
- const resolvedBase = String(baseUrl).trim().replace(/\/+$/, "");
107
- const resolvedChat = chatPath || (String(resolvedBase).includes("/api/v1") ? "/chat/completions" : "/api/v1/chat/completions");
108
- const transport = createTransport({ fetchImpl, dispatcher, keepAlive: !!dispatcher, timeoutMs: connectTimeoutMs, retry: {} });
109
- const sdk = createSdkDispatch({ id, providerName: id || "cline" });
110
-
111
- async function clineFetch(body, sessionId, allowSdk = false) {
112
- const token = await authPool.getAccessToken(body?.model);
113
- appendEvent({ type: "cline-account-state", provider: id || "cline", state: "selected", accountId: acctId(authPool.getCurrentAccount()), model: body?.model, pool: poolStat(authPool.getAccounts(), (a) => authPool.accountAvailable(a, body?.model)) });
114
- const headers = clineHeaders(sessionId, token);
115
- const finalUrl = joinUrl(resolvedBase, resolvedChat);
116
- const isStream = body?.stream === true;
117
- // SDK 通道(缺省):仅客户端显式流式;forceStream 聚合与非流式保持原生(避免丢 nonStreamWithContentCheck)。
118
- if (allowSdk && sdk.enabled) {
119
- const r = await sdk.trySdk({ url: finalUrl, body, headers, fetchImpl });
120
- if (r) return r;
121
- }
122
- return transport.request({ url: finalUrl, headers, body, stream: isStream, timeoutMs: connectTimeoutMs });
123
- }
124
- // 记账回调:捕获当前账号哈希,成功输出进当前额度周期
125
- const track = (model) => ({ tokens, estimated }) => recordOutput({ accountId: acctId(authPool.getCurrentAccount()), model, tokens, estimated }).catch(() => {});
126
-
127
- function isLimitHit(status, bodyText) {
128
- if (status === 429) return true;
129
- if (status >= 500 && String(bodyText).includes("empty response content")) return true;
130
- return false;
131
- }
132
-
133
- async function clineFetchWithRetry(body, sessionId, allowSdk = false) {
134
- const maxRetries = 4;
135
- let lastResp = null;
136
- for (let attempt = 0; attempt <= maxRetries; attempt++) {
137
- const resp0 = await authPool.enqueue(() => clineFetch(body, sessionId, allowSdk));
138
- let resp = resp0;
139
- let bodyText = "";
140
- if (resp.status !== 200) {
141
- try { bodyText = await resp.text(); } catch {}
142
- // 错误响应体已在此读走:原生 transport 的 text() 有缓存,而 SDK Response 的 body 一次性,
143
- // 重建以便上层/客户端仍能读到错误详情(如 429 的 INFERENCE_CAP_ERROR)。
144
- try {
145
- resp = new Response(bodyText, { status: resp0.status, statusText: resp0.statusText, headers: new Headers(resp0.headers) });
146
- } catch {}
147
- }
148
- lastResp = resp;
149
- const hit = isLimitHit(resp.status, bodyText);
150
- if (hit) {
151
- const { parseCooldown } = await import("./auth.js");
152
- const cooldownMs = parseCooldown(bodyText, resp.status);
153
- const cur = authPool.getCurrentAccount();
154
- // 免费额度按「账号 × 模型」计:只把该模型挂到这个号上(limits[model]),
155
- // 不动账号级 cooldownUntil、不清 accessToken——同号其它模型照常可用。
156
- authPool.markLimit(cur, body?.model, cooldownMs);
157
- const limitedModel = limitModelOf(bodyText);
158
- if (limitedModel && limitedModel !== body?.model) authPool.markLimit(cur, limitedModel, cooldownMs);
159
- const cycle = await recordLimit({ accountId: acctId(cur), model: body?.model, reason: resp.status === 429 ? "daily_limit" : "empty_response", status: resp.status, cooldownMs });
160
- const pool = authPool.getAccounts();
161
- const ready = (a) => authPool.accountAvailable(a, body?.model);
162
- const hasOther = pool.some(ready);
163
- // 限流是「账号 × 模型」维度:429 只说明当前账号在这个模型上额度用尽,
164
- // 换模型(如 muse-spark → deepseek)往往仍可用,故日志必须点名模型。
165
- appendEvent({
166
- type: "cline-account-state",
167
- provider: id || "cline",
168
- state: hasOther ? "switch" : "pool-exhausted",
169
- accountId: acctId(cur),
170
- model: body?.model,
171
- limitedModel: limitedModel || undefined,
172
- scope: "model",
173
- reason: resp.status === 429 ? "daily_limit" : "empty_response",
174
- status: resp.status,
175
- cooldownMs,
176
- pool: poolStat(pool, ready),
177
- cycleOutputTokens: cycle?.cycleOutputTokens ?? 0,
178
- cycleCount: cycle?.cycleCount ?? 0,
179
- });
180
- if (!hasOther) return resp;
181
- await sleep(500 + Math.floor(Math.random() * 500));
182
- continue;
183
- }
184
- if (resp.ok) return resp;
185
- return resp;
186
- }
187
- return lastResp;
188
- }
189
-
190
- async function nonStreamWithContentCheck(body, sessionId, firstResp) {
191
- const maxAttempts = 3;
192
- let lastData = null;
193
- let resp = firstResp;
194
- for (let attempt = 0; attempt < maxAttempts; attempt++) {
195
- if (!resp) resp = await clineFetchWithRetry(body, sessionId);
196
- if (!resp.ok) {
197
- const errText = await resp.text().catch(() => "");
198
- const hdrs = new Headers(resp.headers);
199
- const errBody = JSON.stringify({ error: { message: errText.slice(0, 500), type: "api_error" } });
200
- return { error: new Response(errBody, { status: resp.status, headers: hdrs }) };
201
- }
202
- const ct = resp.headers.get("content-type") || "";
203
- let normalized = null;
204
- if (ct.includes("text/event-stream")) normalized = await streamToNonStream(resp, track(body?.model));
205
- else {
206
- const raw = await resp.json().catch(() => null);
207
- if (raw) normalized = unwrapData(raw);
208
- }
209
- if (!normalized) return { error: new Response(JSON.stringify({ error: { message: "upstream returned non-SSE body", type: "api_error" } }), { status: 502 }) };
210
- lastData = normalized;
211
- const msg = normalized?.choices?.[0]?.message || {};
212
- const content = String(msg.content || "").trim();
213
- const reasoning = String(msg.reasoning || "").trim();
214
- const isFallback = msg.reasoning_used_as_content === true;
215
- if (content && !isFallback) return { data: normalized };
216
- if (reasoning || isFallback) {
217
- const cur = authPool.getCurrentAccount();
218
- // 空响应是「模型 × 通道」形态问题(deepseek 非流式特性),同样只挡该模型。
219
- authPool.markLimit(cur, body?.model, 30 * 1000);
220
- appendEvent({ type: "cline-account-state", provider: id || "cline", state: "cooldown", accountId: acctId(cur), model: body?.model, reason: "empty_response", cooldownMs: 30000, scope: "model", pool: poolStat(authPool.getAccounts(), (a) => authPool.accountAvailable(a, body?.model)) });
221
- await sleep(300 + Math.floor(Math.random() * 300));
222
- resp = null;
223
- continue;
224
- }
225
- await sleep(300 + Math.floor(Math.random() * 300));
226
- resp = null;
227
- }
228
- return { data: lastData };
229
- }
230
-
231
- async function runChat(body, ring, sourceKey) {
232
- const model = body?.model || "deepseek/deepseek-v4-flash";
233
- const sessionId = genSessionId();
234
- const isStream = body?.stream === true;
235
- const upstreamModel = stripProviderPrefix(model, id);
236
- // token 上限对标 dsh-cline-pass(DEFAULT_MAX_TOKENS=32000):客户端显式 max 优先,
237
- // 缺省给 32000 而非 4096——旧 4096 默认会在长文处 finish=length 拦腰截断(2026-09-23 实测)。
238
- // reasoning_effort 缺省不发(dsh 同款:调用方自选 none→max,不替上游做 high 假设;
239
- // 高推理+小预算双挤是超大上下文秒回超短答的推手之一);max_completion_tokens 双写保留
240
- //(只发 max_tokens 会被部分通道拒,历史教训)。
241
- const tokLimit = body?.max_tokens || body?.max_completion_tokens || 32000;
242
- const upstreamBody = {
243
- model: upstreamModel,
244
- max_tokens: tokLimit,
245
- max_completion_tokens: tokLimit,
246
- session_id: sessionId,
247
- messages: body?.messages || [],
248
- };
249
- const effort = body?.reasoning_effort || body?.reasoningEffort;
250
- if (effort) upstreamBody.reasoning_effort = effort;
251
- // 部分免费通道(deepseek 家族,含 cline-free/deepseek-*)原生非流式不可靠
252
- //(500 empty response):这类模型在内部走 stream+聚合成 JSON,对外仍按请求方
253
- // stream 标志返回(true 直透,false 聚合)。其它模型完全尊重请求方,不强制。
254
- const forceStream = !isStream && String(upstreamModel).toLowerCase().includes("deepseek");
255
- if (isStream || forceStream) upstreamBody.stream = true;
256
- for (const k of ["temperature", "top_p", "tools", "tool_choice", "stop", "presence_penalty", "frequency_penalty", "response_format", "user", "n", "seed"]) {
257
- if (body[k] !== undefined) upstreamBody[k] = body[k];
258
- }
259
- // Gemini 3.x Flash 经 Vertex 的 maxOutputTokens 上限 65536:客户端(PI-Desktop 默认
260
- // 128000)原样透传即 400 invalid argument(网关 SSE 内 detail:
261
- // "Unable to submit request because it has a maxOutputTokens va...")。
262
- // 2026-09-25 直连实测:max 128000 必 400,65536/32000/500 全 200;
263
- // reasoning_effort=high 单独不触发,仅夹上限。只夹 gemini,其他通道不动。
264
- if (/gemini/i.test(String(upstreamModel || "")) && tokLimit > 65536) {
265
- upstreamBody.max_tokens = 65536;
266
- upstreamBody.max_completion_tokens = 65536;
267
- }
268
- for (let netAttempt = 0; netAttempt < 3; netAttempt++) {
269
- try {
270
- const resp = await clineFetchWithRetry(upstreamBody, sessionId, isStream);
271
- if (!resp) throw new Error("empty response");
272
- if (!resp.ok) return resp;
273
- if (isStream) {
274
- // 旁路观测:把本次选中的账号哈希与裸 model 挂到透传 Response 上,供 relay 记账用。
275
- // 转发链路不读这两个字段;记账失败也不影响返回。见 .agents/notes/implemented/feature/2026-09-24-cline-usage-dualwindow.md
276
- try { resp.clineAccountId = acctId(authPool.getCurrentAccount()); resp.clineModel = model; } catch {}
277
- return resp;
278
- }
279
- if (forceStream) {
280
- const ret = await nonStreamWithContentCheck(upstreamBody, sessionId, resp);
281
- if (ret.error) return ret.error;
282
- ret.data.model = model;
283
- const hdrs = new Headers({ "Content-Type": "application/json" });
284
- return new Response(JSON.stringify(ret.data), { status: 200, headers: hdrs });
285
- }
286
- const raw = await resp.json().catch(() => null);
287
- if (!raw || typeof raw !== "object") {
288
- if (!raw) return resp;
289
- return new Response(JSON.stringify({ error: { message: "upstream returned non-JSON body", type: "api_error" } }), { status: 502, headers: { "Content-Type": "application/json" } });
290
- }
291
- const normalized = unwrapData(raw);
292
- normalized.model = model;
293
- // 非流式终点:正文估算记账(上游有 usage 时 streamToNonStream 路径才拿得到精确值;此处 raw 无流)
294
- const m2 = normalized?.choices?.[0]?.message || {};
295
- const outLen2 = String(m2.content || "").length + String(m2.reasoning || "").length;
296
- if (outLen2 > 0) track(model)({ tokens: Math.ceil(outLen2 / 4), estimated: true });
297
- return new Response(JSON.stringify(normalized), { status: 200, headers: { "Content-Type": "application/json" } });
298
- } catch (err) {
299
- if (netAttempt < 2 && String(err?.message || "").toLowerCase().includes("timed out")) { await sleep(300); continue; }
300
- throw err;
301
- }
302
- }
303
- throw new Error("cline chat failed after retries");
304
- }
305
-
306
- return { runChat, streamToNonStream, _clineFetch: clineFetch };
307
- }
1
+ import { joinUrl, sleep } from "../base.js";
2
+ import { appendEvent } from "../../logs.js";
3
+ import { createHash } from "node:crypto";
4
+ import { clineHeaders } from "./headers.js";
5
+ import { createTransport } from "../../transport/index.js";
6
+ import { normalizeProviderId } from "../model-id.js";
7
+ import { recordLimit, recordOutput, exactOutputTokens } from "./usage.js";
8
+ import { createSdkDispatch } from "../../upstream-engine/sdk/dispatch.js";
9
+
10
+ function genSessionId() { return `sess_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`; }
11
+
12
+ // 只记哈希标识,不落邮箱 / refreshToken(events.log 可读不可泄)
13
+ function acctId(account) {
14
+ const seed = String(account?.refreshToken || "");
15
+ if (!seed) return "slot-unknown";
16
+ return `acct_${createHash("sha256").update(seed).digest("hex").slice(0, 8)}`;
17
+ }
18
+ // 池子快照:ready/cooling 按「该模型」口径统计(ready=对该模型可用的号数)。
19
+ // isReady 由调用方注入 authPool.accountAvailable(a, model),避免与 auth 逻辑分叉。
20
+ function poolStat(pool, isReady = () => true) {
21
+ let cooling = 0;
22
+ let dead = 0;
23
+ for (const a of pool || []) {
24
+ if (a.dead) dead++;
25
+ else if (!isReady(a)) cooling++;
26
+ }
27
+ const total = (pool || []).length;
28
+ return { total, cooling, dead, ready: total - cooling - dead };
29
+ }
30
+
31
+ // 429 错误体自带权威模型名("...Daily free limit reached on model meta/muse-spark-1.3-contributor")。
32
+ function limitModelOf(bodyText) {
33
+ // 模型名可含 / . - _ : 字母数字(如 meta/muse-spark-1.3-contributor),
34
+ // 遇空格/引号/逗号/句号结尾或 "Try again" 前的空白即停。
35
+ const m = String(bodyText || "").match(/on model\s+([A-Za-z0-9/._:-]+?)(?=[\s"',]|\.\s|$)/i);
36
+ return m ? m[1] : "";
37
+ }
38
+
39
+ // stripProviderPrefix 只剥「本供应商」前缀(cline/<裸 id> → 裸 id),非本前缀(含上游自带多段 id
40
+ // 如 meta/vendor/x)原样透传,不误削首段(旧实现按段数剥曾是潜在 bug)。
41
+ function stripProviderPrefix(model, providerId) {
42
+ const s = String(model || "");
43
+ const i = s.indexOf("/");
44
+ if (i <= 0) return s;
45
+ const low = (v) => normalizeProviderId(String(v || "").toLowerCase());
46
+ return low(s.slice(0, i)) === low(providerId) ? s.slice(i + 1) : s;
47
+ }
48
+ function unwrapData(obj) {
49
+ if (obj && obj.data && typeof obj.data === "object") {
50
+ const d = obj.data;
51
+ if (d.choices || d.id || d.usage || d.output) return d;
52
+ }
53
+ return obj;
54
+ }
55
+
56
+ async function streamToNonStream(upstream, track = null) {
57
+ let content = "";
58
+ let reasoning = "";
59
+ let finishReason = null;
60
+ let model = "";
61
+ let id = "";
62
+ let usage = null;
63
+ for await (const ev of upstream.stream()) {
64
+ if (!ev || ev === "[DONE]") continue;
65
+ try {
66
+ const obj = JSON.parse(ev);
67
+ const normalized = unwrapData(obj);
68
+ const choice = normalized?.choices?.[0];
69
+ if (!choice) continue;
70
+ const delta = choice.delta || {};
71
+ if (delta.content) content += delta.content;
72
+ if (delta.reasoning) reasoning += delta.reasoning;
73
+ if (choice.finish_reason) finishReason = choice.finish_reason;
74
+ if (normalized.id) id = normalized.id;
75
+ if (normalized.model) model = normalized.model;
76
+ if (normalized.usage) usage = normalized.usage;
77
+ } catch {}
78
+ }
79
+ const msg = { role: "assistant", content };
80
+ if (reasoning) msg.reasoning = reasoning;
81
+ if (!content && reasoning) { msg.content = reasoning; msg.reasoning_used_as_content = true; }
82
+ if (track) {
83
+ const exact = exactOutputTokens(usage);
84
+ if (exact) track({ tokens: exact, estimated: false });
85
+ else if (content || reasoning) track({ tokens: Math.ceil((content.length + reasoning.length) / 4), estimated: true });
86
+ }
87
+ return {
88
+ id: id || `gen_${Date.now()}`,
89
+ object: "chat.completion",
90
+ created: Math.floor(Date.now() / 1000),
91
+ model: model || "",
92
+ choices: [{ index: 0, message: msg, finish_reason: finishReason || "stop", logprobs: null, native_finish_reason: finishReason || "stop" }],
93
+ usage: usage || { prompt_tokens: 0, completion_tokens: 0, total_tokens: 0 },
94
+ };
95
+ }
96
+
97
+ export function createChatService({
98
+ id,
99
+ baseUrl,
100
+ chatPath,
101
+ fetchImpl,
102
+ dispatcher,
103
+ authPool,
104
+ connectTimeoutMs = 30_000,
105
+ } = {}) {
106
+ const resolvedBase = String(baseUrl).trim().replace(/\/+$/, "");
107
+ const resolvedChat = chatPath || (String(resolvedBase).includes("/api/v1") ? "/chat/completions" : "/api/v1/chat/completions");
108
+ const transport = createTransport({ fetchImpl, dispatcher, keepAlive: !!dispatcher, timeoutMs: connectTimeoutMs, retry: {} });
109
+ const sdk = createSdkDispatch({ id, providerName: id || "cline" });
110
+
111
+ async function clineFetch(body, sessionId, allowSdk = false) {
112
+ const token = await authPool.getAccessToken(body?.model);
113
+ appendEvent({ type: "cline-account-state", provider: id || "cline", state: "selected", accountId: acctId(authPool.getCurrentAccount()), model: body?.model, pool: poolStat(authPool.getAccounts(), (a) => authPool.accountAvailable(a, body?.model)) });
114
+ const headers = clineHeaders(sessionId, token);
115
+ const finalUrl = joinUrl(resolvedBase, resolvedChat);
116
+ const isStream = body?.stream === true;
117
+ // SDK 通道(缺省):仅客户端显式流式;forceStream 聚合与非流式保持原生(避免丢 nonStreamWithContentCheck)。
118
+ if (allowSdk && sdk.enabled) {
119
+ const r = await sdk.trySdk({ url: finalUrl, body, headers, fetchImpl });
120
+ if (r) return r;
121
+ }
122
+ return transport.request({ url: finalUrl, headers, body, stream: isStream, timeoutMs: connectTimeoutMs });
123
+ }
124
+ // 记账回调:捕获当前账号哈希,成功输出进当前额度周期
125
+ const track = (model) => ({ tokens, estimated }) => recordOutput({ accountId: acctId(authPool.getCurrentAccount()), model, tokens, estimated }).catch(() => {});
126
+
127
+ function isLimitHit(status, bodyText) {
128
+ if (status === 429) return true;
129
+ if (status >= 500 && String(bodyText).includes("empty response content")) return true;
130
+ return false;
131
+ }
132
+
133
+ async function clineFetchWithRetry(body, sessionId, allowSdk = false) {
134
+ const maxRetries = 4;
135
+ let lastResp = null;
136
+ for (let attempt = 0; attempt <= maxRetries; attempt++) {
137
+ const resp0 = await authPool.enqueue(() => clineFetch(body, sessionId, allowSdk));
138
+ let resp = resp0;
139
+ let bodyText = "";
140
+ if (resp.status !== 200) {
141
+ try { bodyText = await resp.text(); } catch {}
142
+ // 错误响应体已在此读走:原生 transport 的 text() 有缓存,而 SDK Response 的 body 一次性,
143
+ // 重建以便上层/客户端仍能读到错误详情(如 429 的 INFERENCE_CAP_ERROR)。
144
+ try {
145
+ resp = new Response(bodyText, { status: resp0.status, statusText: resp0.statusText, headers: new Headers(resp0.headers) });
146
+ } catch {}
147
+ }
148
+ lastResp = resp;
149
+ const hit = isLimitHit(resp.status, bodyText);
150
+ if (hit) {
151
+ const { parseCooldown } = await import("./auth.js");
152
+ const cooldownMs = parseCooldown(bodyText, resp.status);
153
+ const cur = authPool.getCurrentAccount();
154
+ // 免费额度按「账号 × 模型」计:只把该模型挂到这个号上(limits[model]),
155
+ // 不动账号级 cooldownUntil、不清 accessToken——同号其它模型照常可用。
156
+ authPool.markLimit(cur, body?.model, cooldownMs);
157
+ const limitedModel = limitModelOf(bodyText);
158
+ if (limitedModel && limitedModel !== body?.model) authPool.markLimit(cur, limitedModel, cooldownMs);
159
+ const cycle = await recordLimit({ accountId: acctId(cur), model: body?.model, reason: resp.status === 429 ? "daily_limit" : "empty_response", status: resp.status, cooldownMs });
160
+ const pool = authPool.getAccounts();
161
+ const ready = (a) => authPool.accountAvailable(a, body?.model);
162
+ const hasOther = pool.some(ready);
163
+ // 限流是「账号 × 模型」维度:429 只说明当前账号在这个模型上额度用尽,
164
+ // 换模型(如 muse-spark → deepseek)往往仍可用,故日志必须点名模型。
165
+ appendEvent({
166
+ type: "cline-account-state",
167
+ provider: id || "cline",
168
+ state: hasOther ? "switch" : "pool-exhausted",
169
+ accountId: acctId(cur),
170
+ model: body?.model,
171
+ limitedModel: limitedModel || undefined,
172
+ scope: "model",
173
+ reason: resp.status === 429 ? "daily_limit" : "empty_response",
174
+ status: resp.status,
175
+ cooldownMs,
176
+ pool: poolStat(pool, ready),
177
+ cycleOutputTokens: cycle?.cycleOutputTokens ?? 0,
178
+ cycleCount: cycle?.cycleCount ?? 0,
179
+ });
180
+ if (!hasOther) return resp;
181
+ await sleep(500 + Math.floor(Math.random() * 500));
182
+ continue;
183
+ }
184
+ if (resp.ok) return resp;
185
+ return resp;
186
+ }
187
+ return lastResp;
188
+ }
189
+
190
+ async function nonStreamWithContentCheck(body, sessionId, firstResp) {
191
+ const maxAttempts = 3;
192
+ let lastData = null;
193
+ let resp = firstResp;
194
+ for (let attempt = 0; attempt < maxAttempts; attempt++) {
195
+ if (!resp) resp = await clineFetchWithRetry(body, sessionId);
196
+ if (!resp.ok) {
197
+ const errText = await resp.text().catch(() => "");
198
+ const hdrs = new Headers(resp.headers);
199
+ const errBody = JSON.stringify({ error: { message: errText.slice(0, 500), type: "api_error" } });
200
+ return { error: new Response(errBody, { status: resp.status, headers: hdrs }) };
201
+ }
202
+ const ct = resp.headers.get("content-type") || "";
203
+ let normalized = null;
204
+ if (ct.includes("text/event-stream")) normalized = await streamToNonStream(resp, track(body?.model));
205
+ else {
206
+ const raw = await resp.json().catch(() => null);
207
+ if (raw) normalized = unwrapData(raw);
208
+ }
209
+ if (!normalized) return { error: new Response(JSON.stringify({ error: { message: "upstream returned non-SSE body", type: "api_error" } }), { status: 502 }) };
210
+ lastData = normalized;
211
+ const msg = normalized?.choices?.[0]?.message || {};
212
+ const content = String(msg.content || "").trim();
213
+ const reasoning = String(msg.reasoning || "").trim();
214
+ const isFallback = msg.reasoning_used_as_content === true;
215
+ if (content && !isFallback) return { data: normalized };
216
+ if (reasoning || isFallback) {
217
+ const cur = authPool.getCurrentAccount();
218
+ // 空响应是「模型 × 通道」形态问题(deepseek 非流式特性),同样只挡该模型。
219
+ authPool.markLimit(cur, body?.model, 30 * 1000);
220
+ appendEvent({ type: "cline-account-state", provider: id || "cline", state: "cooldown", accountId: acctId(cur), model: body?.model, reason: "empty_response", cooldownMs: 30000, scope: "model", pool: poolStat(authPool.getAccounts(), (a) => authPool.accountAvailable(a, body?.model)) });
221
+ await sleep(300 + Math.floor(Math.random() * 300));
222
+ resp = null;
223
+ continue;
224
+ }
225
+ await sleep(300 + Math.floor(Math.random() * 300));
226
+ resp = null;
227
+ }
228
+ return { data: lastData };
229
+ }
230
+
231
+ async function runChat(body, ring, sourceKey) {
232
+ const model = body?.model || "deepseek/deepseek-v4-flash";
233
+ const sessionId = genSessionId();
234
+ const isStream = body?.stream === true;
235
+ const upstreamModel = stripProviderPrefix(model, id);
236
+ // token 上限对标 dsh-cline-pass(DEFAULT_MAX_TOKENS=32000):客户端显式 max 优先,
237
+ // 缺省给 32000 而非 4096——旧 4096 默认会在长文处 finish=length 拦腰截断(2026-09-23 实测)。
238
+ // reasoning_effort 缺省不发(dsh 同款:调用方自选 none→max,不替上游做 high 假设;
239
+ // 高推理+小预算双挤是超大上下文秒回超短答的推手之一);max_completion_tokens 双写保留
240
+ //(只发 max_tokens 会被部分通道拒,历史教训)。
241
+ const tokLimit = body?.max_tokens || body?.max_completion_tokens || 32000;
242
+ const upstreamBody = {
243
+ model: upstreamModel,
244
+ max_tokens: tokLimit,
245
+ max_completion_tokens: tokLimit,
246
+ session_id: sessionId,
247
+ messages: body?.messages || [],
248
+ };
249
+ const effort = body?.reasoning_effort || body?.reasoningEffort;
250
+ if (effort) upstreamBody.reasoning_effort = effort;
251
+ // 部分免费通道(deepseek 家族,含 cline-free/deepseek-*)原生非流式不可靠
252
+ //(500 empty response):这类模型在内部走 stream+聚合成 JSON,对外仍按请求方
253
+ // stream 标志返回(true 直透,false 聚合)。其它模型完全尊重请求方,不强制。
254
+ const forceStream = !isStream && String(upstreamModel).toLowerCase().includes("deepseek");
255
+ if (isStream || forceStream) upstreamBody.stream = true;
256
+ for (const k of ["temperature", "top_p", "tools", "tool_choice", "stop", "presence_penalty", "frequency_penalty", "response_format", "user", "n", "seed"]) {
257
+ if (body[k] !== undefined) upstreamBody[k] = body[k];
258
+ }
259
+ // Gemini 3.x Flash 经 Vertex 的 maxOutputTokens 上限 65536:客户端(PI-Desktop 默认
260
+ // 128000)原样透传即 400 invalid argument(网关 SSE 内 detail:
261
+ // "Unable to submit request because it has a maxOutputTokens va...")。
262
+ // 2026-09-25 直连实测:max 128000 必 400,65536/32000/500 全 200;
263
+ // reasoning_effort=high 单独不触发,仅夹上限。只夹 gemini,其他通道不动。
264
+ if (/gemini/i.test(String(upstreamModel || "")) && tokLimit > 65536) {
265
+ upstreamBody.max_tokens = 65536;
266
+ upstreamBody.max_completion_tokens = 65536;
267
+ }
268
+ for (let netAttempt = 0; netAttempt < 3; netAttempt++) {
269
+ try {
270
+ const resp = await clineFetchWithRetry(upstreamBody, sessionId, isStream);
271
+ if (!resp) throw new Error("empty response");
272
+ if (!resp.ok) return resp;
273
+ if (isStream) {
274
+ // 旁路观测:把本次选中的账号哈希与裸 model 挂到透传 Response 上,供 relay 记账用。
275
+ // 转发链路不读这两个字段;记账失败也不影响返回。见 .agents/notes/implemented/feature/2026-09-24-cline-usage-dualwindow.md
276
+ try { resp.clineAccountId = acctId(authPool.getCurrentAccount()); resp.clineModel = model; } catch {}
277
+ return resp;
278
+ }
279
+ if (forceStream) {
280
+ const ret = await nonStreamWithContentCheck(upstreamBody, sessionId, resp);
281
+ if (ret.error) return ret.error;
282
+ ret.data.model = model;
283
+ const hdrs = new Headers({ "Content-Type": "application/json" });
284
+ return new Response(JSON.stringify(ret.data), { status: 200, headers: hdrs });
285
+ }
286
+ const raw = await resp.json().catch(() => null);
287
+ if (!raw || typeof raw !== "object") {
288
+ if (!raw) return resp;
289
+ return new Response(JSON.stringify({ error: { message: "upstream returned non-JSON body", type: "api_error" } }), { status: 502, headers: { "Content-Type": "application/json" } });
290
+ }
291
+ const normalized = unwrapData(raw);
292
+ normalized.model = model;
293
+ // 非流式终点:正文估算记账(上游有 usage 时 streamToNonStream 路径才拿得到精确值;此处 raw 无流)
294
+ const m2 = normalized?.choices?.[0]?.message || {};
295
+ const outLen2 = String(m2.content || "").length + String(m2.reasoning || "").length;
296
+ if (outLen2 > 0) track(model)({ tokens: Math.ceil(outLen2 / 4), estimated: true });
297
+ return new Response(JSON.stringify(normalized), { status: 200, headers: { "Content-Type": "application/json" } });
298
+ } catch (err) {
299
+ if (netAttempt < 2 && String(err?.message || "").toLowerCase().includes("timed out")) { await sleep(300); continue; }
300
+ throw err;
301
+ }
302
+ }
303
+ throw new Error("cline chat failed after retries");
304
+ }
305
+
306
+ return { runChat, streamToNonStream, _clineFetch: clineFetch };
307
+ }
@@ -1,2 +1,2 @@
1
- // Shim — 保持历史导入路径兼容,真实实现下沉至 ./cline/ 深模块
2
- export * from "./cline/index.js";
1
+ // Shim — 保持历史导入路径兼容,真实实现下沉至 ./cline/ 深模块
2
+ export * from "./cline/index.js";