dsh-provider-proxy 0.4.1 → 0.5.0

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/lib/index.js CHANGED
@@ -44,10 +44,18 @@ function installSharedRouter() {
44
44
  return originalFetch.call(globalThis, input, init);
45
45
  }
46
46
  active.onHit?.();
47
- return undiciFetch(input, {
47
+ const inFlight = undiciFetch(input, {
48
48
  ...init,
49
49
  dispatcher: active.dispatcher
50
50
  });
51
+ if (active.onSettle !== void 0) {
52
+ const settle = active.onSettle;
53
+ void inFlight.then(
54
+ (response) => settle(response.status < 500, response),
55
+ (error) => settle(false, error)
56
+ ).catch(() => void 0);
57
+ }
58
+ return inFlight;
51
59
  };
52
60
  Object.defineProperty(globalThis, "fetch", {
53
61
  ...descriptor ?? { configurable: true, enumerable: true, writable: true },
@@ -1017,9 +1025,27 @@ function vol(field) {
1017
1025
  return typeof volatile2 === "function" ? volatile2.call(field) : field;
1018
1026
  }
1019
1027
  var PROTOCOLS = ["http", "https", "socks5"];
1028
+ var DefaultProxy = z.object({
1029
+ protocol: z.union(PROTOCOLS).default("http"),
1030
+ /** Proxy host or IP, without scheme, port, or path. */
1031
+ host: z.string().default(""),
1032
+ /** Proxy port. 0 means "not configured". */
1033
+ port: z.number().default(0),
1034
+ username: z.string().default(""),
1035
+ password: z.string().default("")
1036
+ });
1020
1037
  var ProviderProxyRoute = z.object({
1021
1038
  /** Whether this provider's calls are routed through the proxy below. */
1022
1039
  enabled: z.boolean().default(false),
1040
+ /**
1041
+ * When true, this route's TRANSPORT fields (protocol/host/port/username/
1042
+ * password) come from the top-level `defaultProxy` instead of its own, so the
1043
+ * same endpoint is configured once for every provider that shares it. The
1044
+ * target-host rules and the self-check target stay the route's own. There is
1045
+ * no partial inheritance: to use a different endpoint, turn this off and fill
1046
+ * the fields in.
1047
+ */
1048
+ useDefault: z.boolean().default(false),
1023
1049
  /** Proxy transport. `socks5` is accepted but only honoured when the socks helper loaded. */
1024
1050
  protocol: z.union(PROTOCOLS).default("http"),
1025
1051
  /** Proxy host or IP, without scheme, port, or path. */
@@ -1036,11 +1062,31 @@ var ProviderProxyRoute = z.object({
1036
1062
  */
1037
1063
  onlyHosts: z.string().default(""),
1038
1064
  /** Comma/space-separated TARGET hosts that bypass the proxy; takes priority over `onlyHosts`. */
1039
- bypassHosts: z.string().default("")
1065
+ bypassHosts: z.string().default(""),
1066
+ /**
1067
+ * Optional self-check target for THIS provider's 测试 button. Empty falls back
1068
+ * to the top-level `testUrl`. Only the self-check reads it; routing never
1069
+ * touches it, so a bad value costs a failed check, never a broken route.
1070
+ */
1071
+ testTarget: z.string().default("")
1040
1072
  });
1041
1073
  var Config = z.object({
1042
1074
  /** Per-provider proxy routes, keyed by provider route id (e.g. `sensenova`). */
1043
1075
  providers: vol(z.dict(ProviderProxyRoute).default({})),
1076
+ /**
1077
+ * Shared endpoint for every route that sets `useDefault`. Volatile for the
1078
+ * same reason as `providers`: a settings write is committed into the running
1079
+ * fiber instead of remounting the plugin.
1080
+ */
1081
+ defaultProxy: vol(DefaultProxy.default({})),
1082
+ /**
1083
+ * Whether a loopback/private-address target is sent DIRECT instead of through
1084
+ * the proxy. On by default: such a target is normally a relay on this very
1085
+ * machine (LiteLLM, Ollama, a local mock), which a forward proxy cannot reach
1086
+ * at all — forcing it through one fails, and the failure reads as "the proxy
1087
+ * is broken". Turn this off only when the proxy itself is the target.
1088
+ */
1089
+ localDirect: vol(z.boolean().default(true)),
1044
1090
  /**
1045
1091
  * URL the per-provider connection self-check requests THROUGH the proxy.
1046
1092
  * Volatile, so a settings write does not remount the plugin; the default is an
@@ -1048,12 +1094,28 @@ var Config = z.object({
1048
1094
  */
1049
1095
  testUrl: vol(z.string().default("https://api.ipify.org?format=json"))
1050
1096
  });
1051
- function normalizeRoute(provider, raw) {
1052
- const route = ProviderProxyRoute(raw ?? {});
1053
- const value = typeof route.get === "function" ? route.get() : route;
1097
+ function plain(parsed) {
1098
+ const get = parsed.get;
1099
+ return typeof get === "function" ? get.call(parsed) : parsed;
1100
+ }
1101
+ function normalizeDefaultProxy(raw) {
1102
+ const value = plain(DefaultProxy(raw ?? {}));
1103
+ return {
1104
+ protocol: value.protocol,
1105
+ host: value.host.trim(),
1106
+ port: value.port,
1107
+ username: value.username.trim(),
1108
+ password: value.password
1109
+ };
1110
+ }
1111
+ function normalizeRoute(provider, raw, options) {
1112
+ const value = plain(ProviderProxyRoute(raw ?? {}));
1054
1113
  const onlyHosts = value.onlyHosts.trim().toLowerCase();
1055
1114
  const bypassHosts = value.bypassHosts.trim().toLowerCase();
1056
- if (!value.enabled) return { ...value, port: value.port || 0, onlyHosts, bypassHosts };
1115
+ const testTarget = value.testTarget.trim();
1116
+ if (!value.enabled && options?.requireEnabled !== false) {
1117
+ return { ...value, port: value.port || 0, onlyHosts, bypassHosts, testTarget };
1118
+ }
1057
1119
  const host = value.host.trim();
1058
1120
  if (host.length === 0) {
1059
1121
  throw new TypeError(`dsh-provider-proxy: provider "${provider}" \u542F\u7528\u4E86\u4EE3\u7406\uFF0C\u4F46\u672A\u586B\u5199\u4EE3\u7406\u5730\u5740`);
@@ -1066,7 +1128,26 @@ function normalizeRoute(provider, raw) {
1066
1128
  `dsh-provider-proxy: provider "${provider}" \u7684\u4EE3\u7406\u7AEF\u53E3\u65E0\u6548\uFF08\u9700\u4E3A 1-65535 \u7684\u6574\u6570\uFF09`
1067
1129
  );
1068
1130
  }
1069
- return { ...value, host, username: value.username.trim(), onlyHosts, bypassHosts };
1131
+ return { ...value, host, username: value.username.trim(), onlyHosts, bypassHosts, testTarget };
1132
+ }
1133
+
1134
+ // src/proxy-address.ts
1135
+ function defaultProxyProblem(endpoint) {
1136
+ const host = (endpoint.host ?? "").trim();
1137
+ if (host.length === 0) return "\u672A\u586B\u5199\u4EE3\u7406\u5730\u5740";
1138
+ if (/^[a-z][a-z0-9+.-]*:\/\//i.test(host) || host.includes("/")) {
1139
+ return "\u4EE3\u7406\u5730\u5740\u53EA\u586B\u4E3B\u673A\u540D\u6216 IP\uFF0C\u4E0D\u8981\u5E26\u534F\u8BAE\u524D\u7F00\u6216\u8DEF\u5F84";
1140
+ }
1141
+ const port = endpoint.port ?? 0;
1142
+ if (!Number.isInteger(port) || port < 1 || port > 65535) {
1143
+ return "\u4EE3\u7406\u7AEF\u53E3\u65E0\u6548\uFF08\u9700\u4E3A 1-65535 \u7684\u6574\u6570\uFF09";
1144
+ }
1145
+ const hasUser = (endpoint.username ?? "").length > 0;
1146
+ const hasPassword = (endpoint.password ?? "").length > 0;
1147
+ if (hasUser !== hasPassword) {
1148
+ return hasUser ? "\u914D\u7F6E\u4E86\u4EE3\u7406\u7528\u6237\u540D\u4F46\u6CA1\u6709\u5BC6\u7801\uFF1B\u8BF7\u8865\u5168\u5BC6\u7801\u6216\u6E05\u7A7A\u7528\u6237\u540D" : "\u914D\u7F6E\u4E86\u4EE3\u7406\u5BC6\u7801\u4F46\u6CA1\u6709\u7528\u6237\u540D\uFF1B\u8BF7\u8865\u5168\u7528\u6237\u540D\u6216\u6E05\u7A7A\u5BC6\u7801";
1149
+ }
1150
+ return void 0;
1070
1151
  }
1071
1152
 
1072
1153
  // src/proxy-table.ts
@@ -1099,70 +1180,115 @@ function hostMatches(host, rules) {
1099
1180
  }
1100
1181
  return false;
1101
1182
  }
1102
- function buildDecide(route) {
1183
+ function isLocalHost(host) {
1184
+ const bare = host.startsWith("[") && host.endsWith("]") ? host.slice(1, -1) : host;
1185
+ if (bare === "localhost" || bare.endsWith(".localhost")) return true;
1186
+ if (bare === "::1") return true;
1187
+ const octets = /^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/.exec(bare);
1188
+ if (octets === null) return false;
1189
+ const [a, b, c, d] = octets.slice(1).map(Number);
1190
+ if (a > 255 || b > 255 || c > 255 || d > 255) return false;
1191
+ if (a === 127) return true;
1192
+ if (a === 10) return true;
1193
+ if (a === 192 && b === 168) return true;
1194
+ if (a === 169 && b === 254) return true;
1195
+ if (a === 172 && b >= 16 && b <= 31) return true;
1196
+ return false;
1197
+ }
1198
+ function buildDecide(route, localDirect) {
1103
1199
  const only = parseHostList(route.onlyHosts);
1104
1200
  const bypass = parseHostList(route.bypassHosts);
1105
1201
  return (host) => {
1106
1202
  if (host === void 0) return "proxy";
1203
+ if (localDirect && isLocalHost(host)) return "direct";
1107
1204
  if (hostMatches(host, bypass)) return "direct";
1108
1205
  if (only.length > 0 && !hostMatches(host, only)) return "direct";
1109
1206
  return "proxy";
1110
1207
  };
1111
1208
  }
1112
- function createProxyTable(config) {
1113
- const entries = /* @__PURE__ */ new Map();
1114
- const problems = /* @__PURE__ */ new Map();
1115
- for (const [provider, raw] of Object.entries(config ?? {})) {
1116
- let route;
1117
- try {
1118
- route = normalizeRoute(provider, raw);
1119
- } catch (error) {
1120
- problems.set(provider, error instanceof Error ? error.message : String(error));
1121
- continue;
1122
- }
1123
- if (!route.enabled) continue;
1124
- if (route.protocol === "socks5" && route.username.length > 0 && route.password.length === 0) {
1125
- problems.set(
1126
- provider,
1127
- "dsh-provider-proxy: SOCKS5 \u914D\u7F6E\u4E86\u7528\u6237\u540D\u4F46\u6CA1\u6709\u5BC6\u7801\uFF1B\u8BF7\u8865\u5168\u5BC6\u7801\u6216\u6E05\u7A7A\u7528\u6237\u540D"
1128
- );
1129
- continue;
1130
- }
1131
- if (route.protocol === "socks5" && route.password.length > 0 && route.username.length === 0) {
1132
- problems.set(
1133
- provider,
1134
- "dsh-provider-proxy: SOCKS5 \u914D\u7F6E\u4E86\u5BC6\u7801\u4F46\u6CA1\u6709\u7528\u6237\u540D\uFF1B\u8BF7\u8865\u5168\u7528\u6237\u540D\u6216\u6E05\u7A7A\u5BC6\u7801"
1135
- );
1136
- continue;
1137
- }
1138
- if (route.protocol !== "socks5" && route.password.length > 0 && route.username.length === 0) {
1139
- problems.set(
1140
- provider,
1141
- "dsh-provider-proxy: \u914D\u7F6E\u4E86\u4EE3\u7406\u5BC6\u7801\u4F46\u6CA1\u6709\u7528\u6237\u540D\uFF1B\u8BF7\u8865\u5168\u7528\u6237\u540D\u6216\u6E05\u7A7A\u5BC6\u7801"
1142
- );
1143
- continue;
1144
- }
1145
- if (route.protocol !== "socks5" && route.username.length > 0 && route.password.length === 0) {
1146
- problems.set(
1147
- provider,
1148
- "dsh-provider-proxy: \u914D\u7F6E\u4E86\u4EE3\u7406\u7528\u6237\u540D\u4F46\u6CA1\u6709\u5BC6\u7801\uFF1B\u8BF7\u8865\u5168\u5BC6\u7801\u6216\u6E05\u7A7A\u7528\u6237\u540D"
1149
- );
1150
- continue;
1151
- }
1152
- const url = proxyUrl(route);
1153
- let dispatcher;
1154
- try {
1155
- dispatcher = createDispatcher(route, url, route.password);
1156
- } catch (error) {
1157
- problems.set(provider, error instanceof Error ? error.message : String(error));
1158
- continue;
1209
+ function withDefaultProxy(route, defaultProxy) {
1210
+ return {
1211
+ ...route,
1212
+ protocol: defaultProxy.protocol,
1213
+ host: defaultProxy.host,
1214
+ port: defaultProxy.port,
1215
+ username: defaultProxy.username,
1216
+ password: defaultProxy.password
1217
+ };
1218
+ }
1219
+ function buildRouteEntry(provider, raw, options) {
1220
+ let effective = raw;
1221
+ if (raw?.useDefault === true) {
1222
+ const why = defaultProxyProblem(options.defaultProxy);
1223
+ if (why !== void 0) {
1224
+ return {
1225
+ ok: false,
1226
+ problem: `dsh-provider-proxy: provider "${provider}" \u542F\u7528\u4E86\u300C\u4F7F\u7528\u9ED8\u8BA4\u4EE3\u7406\u300D\uFF0C\u4F46\u9876\u5C42\u9ED8\u8BA4\u4EE3\u7406\u4E0D\u53EF\u7528\uFF1A${why}`
1227
+ };
1159
1228
  }
1160
- entries.set(provider, {
1229
+ effective = withDefaultProxy(raw, options.defaultProxy);
1230
+ }
1231
+ let route;
1232
+ try {
1233
+ route = normalizeRoute(provider, effective, { requireEnabled: options.requireEnabled });
1234
+ } catch (error) {
1235
+ return { ok: false, problem: error instanceof Error ? error.message : String(error) };
1236
+ }
1237
+ if (route.protocol === "socks5" && route.username.length > 0 && route.password.length === 0) {
1238
+ return {
1239
+ ok: false,
1240
+ problem: "dsh-provider-proxy: SOCKS5 \u914D\u7F6E\u4E86\u7528\u6237\u540D\u4F46\u6CA1\u6709\u5BC6\u7801\uFF1B\u8BF7\u8865\u5168\u5BC6\u7801\u6216\u6E05\u7A7A\u7528\u6237\u540D"
1241
+ };
1242
+ }
1243
+ if (route.protocol === "socks5" && route.password.length > 0 && route.username.length === 0) {
1244
+ return {
1245
+ ok: false,
1246
+ problem: "dsh-provider-proxy: SOCKS5 \u914D\u7F6E\u4E86\u5BC6\u7801\u4F46\u6CA1\u6709\u7528\u6237\u540D\uFF1B\u8BF7\u8865\u5168\u7528\u6237\u540D\u6216\u6E05\u7A7A\u5BC6\u7801"
1247
+ };
1248
+ }
1249
+ if (route.protocol !== "socks5" && route.password.length > 0 && route.username.length === 0) {
1250
+ return {
1251
+ ok: false,
1252
+ problem: "dsh-provider-proxy: \u914D\u7F6E\u4E86\u4EE3\u7406\u5BC6\u7801\u4F46\u6CA1\u6709\u7528\u6237\u540D\uFF1B\u8BF7\u8865\u5168\u7528\u6237\u540D\u6216\u6E05\u7A7A\u5BC6\u7801"
1253
+ };
1254
+ }
1255
+ if (route.protocol !== "socks5" && route.username.length > 0 && route.password.length === 0) {
1256
+ return {
1257
+ ok: false,
1258
+ problem: "dsh-provider-proxy: \u914D\u7F6E\u4E86\u4EE3\u7406\u7528\u6237\u540D\u4F46\u6CA1\u6709\u5BC6\u7801\uFF1B\u8BF7\u8865\u5168\u5BC6\u7801\u6216\u6E05\u7A7A\u7528\u6237\u540D"
1259
+ };
1260
+ }
1261
+ const url = proxyUrl(route);
1262
+ let dispatcher;
1263
+ try {
1264
+ dispatcher = createDispatcher(route, url, route.password);
1265
+ } catch (error) {
1266
+ return { ok: false, problem: error instanceof Error ? error.message : String(error) };
1267
+ }
1268
+ return {
1269
+ ok: true,
1270
+ entry: {
1161
1271
  provider,
1162
1272
  proxyOrigin: displayOrigin(route, url),
1163
1273
  dispatcher,
1164
- decide: buildDecide(route)
1165
- });
1274
+ decide: buildDecide(route, options.localDirect),
1275
+ testTarget: route.testTarget
1276
+ }
1277
+ };
1278
+ }
1279
+ function createProxyTable(config, options = {}) {
1280
+ const entries = /* @__PURE__ */ new Map();
1281
+ const problems = /* @__PURE__ */ new Map();
1282
+ const buildOptions = {
1283
+ defaultProxy: normalizeDefaultProxy(options.defaultProxy),
1284
+ requireEnabled: true,
1285
+ localDirect: options.localDirect !== false
1286
+ };
1287
+ for (const [provider, raw] of Object.entries(config ?? {})) {
1288
+ if (raw?.enabled !== true) continue;
1289
+ const built = buildRouteEntry(provider, raw, buildOptions);
1290
+ if (!built.ok) problems.set(provider, built.problem);
1291
+ else entries.set(provider, built.entry);
1166
1292
  }
1167
1293
  return { entries, problems };
1168
1294
  }
@@ -1183,6 +1309,20 @@ function plainProviders(config) {
1183
1309
  const value = typeof snapshot2 === "function" ? snapshot2.call(raw) : raw;
1184
1310
  return value ?? {};
1185
1311
  }
1312
+ function plainDefaultProxy(config) {
1313
+ const raw = config?.defaultProxy;
1314
+ if (raw === void 0) return {};
1315
+ const snapshot2 = raw.get;
1316
+ const value = typeof snapshot2 === "function" ? snapshot2.call(raw) : raw;
1317
+ return value ?? {};
1318
+ }
1319
+ function plainLocalDirect(config) {
1320
+ const raw = config?.localDirect;
1321
+ if (raw === void 0) return true;
1322
+ const snapshot2 = raw.get;
1323
+ const value = typeof snapshot2 === "function" ? snapshot2.call(raw) : raw;
1324
+ return value !== false;
1325
+ }
1186
1326
  function apply(ctx, config) {
1187
1327
  const router = acquireFetchRouter();
1188
1328
  let current = /* @__PURE__ */ new Map();
@@ -1190,18 +1330,67 @@ function apply(ctx, config) {
1190
1330
  let closed = false;
1191
1331
  let webAvailable = false;
1192
1332
  const hits = /* @__PURE__ */ new Map();
1333
+ const lastUse = /* @__PURE__ */ new Map();
1193
1334
  const bump = (provider) => {
1194
1335
  hits.set(provider, (hits.get(provider) ?? 0) + 1);
1336
+ lastUse.set(provider, { ...lastUse.get(provider), startedAt: Date.now() });
1337
+ };
1338
+ const settle = (provider) => (ok, detail) => {
1339
+ const previous = lastUse.get(provider);
1340
+ lastUse.set(provider, {
1341
+ startedAt: previous?.startedAt ?? Date.now(),
1342
+ // `at` is the FINISH time, written together with the outcome it belongs to.
1343
+ at: Date.now(),
1344
+ ok,
1345
+ code: ok ? String(detail?.status ?? "") : failureCodeOf(detail)
1346
+ });
1195
1347
  };
1196
1348
  const DEFAULT_TEST_URL = "https://api.ipify.org?format=json";
1349
+ const MAX_TEST_TARGETS = 5;
1197
1350
  const TEST_TIMEOUT_MS = 1e4;
1198
1351
  const MAX_REQUEST_BYTES = 8 * 1024;
1199
1352
  const selfChecks = /* @__PURE__ */ new Map();
1200
- const plainTestUrl = () => {
1353
+ const SELF_CHECK_COOLDOWN_MS = 3e3;
1354
+ const lastChecks = /* @__PURE__ */ new Map();
1355
+ const plainTestUrls = () => {
1201
1356
  const raw = config?.testUrl;
1202
1357
  const snapshot2 = raw?.get;
1203
1358
  const value = typeof snapshot2 === "function" ? snapshot2.call(raw) : raw;
1204
- return typeof value === "string" && value.trim().length > 0 ? value.trim() : DEFAULT_TEST_URL;
1359
+ const list = typeof value === "string" ? value.split(/[,\s]+/).map((entry) => entry.trim()).filter((entry) => entry.length > 0) : [];
1360
+ return list.length > 0 ? list.slice(0, MAX_TEST_TARGETS) : [DEFAULT_TEST_URL];
1361
+ };
1362
+ const walk = (value, path) => {
1363
+ let node = value;
1364
+ for (const key of path) {
1365
+ if (node === null || typeof node !== "object") return void 0;
1366
+ node = node[key];
1367
+ }
1368
+ return node;
1369
+ };
1370
+ const upstreamOf = (provider) => {
1371
+ let settings;
1372
+ try {
1373
+ settings = typeof ctx?.get === "function" ? ctx.get("settings") : void 0;
1374
+ } catch {
1375
+ return void 0;
1376
+ }
1377
+ if (settings === void 0 || typeof settings.get !== "function") return void 0;
1378
+ let entry;
1379
+ try {
1380
+ const entries = ctx.llm?.listConfigurableProviders?.() ?? [];
1381
+ entry = entries.find((candidate) => candidate?.provider === provider);
1382
+ } catch {
1383
+ return void 0;
1384
+ }
1385
+ if (entry === void 0) return void 0;
1386
+ let profile;
1387
+ try {
1388
+ profile = walk(settings.get(entry.settingsNs), entry.settingsPath ?? []);
1389
+ } catch {
1390
+ return void 0;
1391
+ }
1392
+ const baseURL = profile?.baseURL;
1393
+ return typeof baseURL === "string" && baseURL.trim().length > 0 ? baseURL.trim() : void 0;
1205
1394
  };
1206
1395
  const bareMessage = (message) => message.replace(/^dsh-provider-proxy:\s*/, "");
1207
1396
  const causesOf = (error) => {
@@ -1220,10 +1409,11 @@ function apply(ctx, config) {
1220
1409
  }
1221
1410
  return out;
1222
1411
  };
1223
- const classifyTestError = (error) => {
1412
+ const classifyTestError = (error, viaProxy) => {
1224
1413
  const chain = causesOf(error);
1225
1414
  const names = chain.map((node) => node.name).filter((name2) => typeof name2 === "string");
1226
1415
  const codes = chain.map((node) => node.code).filter((code2) => typeof code2 === "string");
1416
+ const messages = chain.map((node) => node.message).filter((text) => typeof text === "string");
1227
1417
  const message = error instanceof Error ? error.message : String(error);
1228
1418
  if (names.includes("TimeoutError") || names.includes("AbortError")) {
1229
1419
  return { kind: "timeout", error: `\u8D85\u65F6\uFF08${TEST_TIMEOUT_MS / 1e3}s\uFF09\uFF1A\u4EE3\u7406\u6216\u76EE\u6807\u65E0\u54CD\u5E94` };
@@ -1231,31 +1421,43 @@ function apply(ctx, config) {
1231
1421
  const connectivity = ["ECONNREFUSED", "ENOTFOUND", "EHOSTUNREACH", "ENETUNREACH", "ECONNRESET", "EAI_AGAIN"];
1232
1422
  const code = connectivity.find((candidate) => codes.includes(candidate));
1233
1423
  if (code !== void 0) {
1234
- return { kind: "proxy-unreachable", error: `\u4EE3\u7406\u4E0D\u53EF\u8FBE\uFF08${code}\uFF09\uFF1A${message}` };
1424
+ return viaProxy ? { kind: "proxy-unreachable", error: `\u4EE3\u7406\u4E0D\u53EF\u8FBE\uFF08${code}\uFF09\uFF1A${message}` } : { kind: "target", error: `\u76EE\u6807\u4E0D\u53EF\u8FBE\uFF08${code}\uFF09\uFF1A${message}` };
1235
1425
  }
1236
- if (names.includes("ProxyAuthentication") || message.includes("407")) {
1426
+ if (names.includes("ProxyAuthentication") || messages.some((text) => text.includes("407"))) {
1237
1427
  return { kind: "proxy-auth", error: `\u4EE3\u7406\u8981\u6C42\u8BA4\u8BC1\uFF08HTTP 407\uFF09\uFF1A${message}` };
1238
1428
  }
1239
1429
  return { kind: "target", error: `\u8BF7\u6C42\u5931\u8D25\uFF1A${message}` };
1240
1430
  };
1241
- const runSelfCheck = async (provider) => {
1242
- const route = current.get(provider);
1243
- if (route === void 0) {
1244
- const problem = problems.get(provider);
1245
- if (problem !== void 0) {
1246
- return { ok: false, kind: "route-rejected", error: bareMessage(problem) };
1247
- }
1248
- return { ok: false, kind: "no-route", error: `provider "${provider}" \u672A\u542F\u7528\u4EE3\u7406` };
1431
+ const failureCodeOf = (error) => {
1432
+ const chain = causesOf(error);
1433
+ const names = chain.map((node) => node.name).filter((name2) => typeof name2 === "string");
1434
+ if (names.includes("TimeoutError")) return "TIMEOUT";
1435
+ if (names.includes("AbortError")) return "ABORTED";
1436
+ for (const node of chain) {
1437
+ const code = node.code;
1438
+ if (typeof code === "string" && code.length > 0) return code;
1439
+ }
1440
+ return "ERROR";
1441
+ };
1442
+ const probeTarget = async (target, route) => {
1443
+ let host;
1444
+ try {
1445
+ host = new URL(target).hostname;
1446
+ } catch {
1447
+ return { ok: false, kind: "target", error: `\u81EA\u68C0\u76EE\u6807\u4E0D\u662F\u5408\u6CD5 URL\uFF1A${target}` };
1249
1448
  }
1449
+ const viaProxy = route.decide(host) === "proxy";
1250
1450
  const started = performance.now();
1251
1451
  try {
1252
- const response = await undiciFetch2(plainTestUrl(), {
1253
- dispatcher: route.dispatcher,
1452
+ const response = await undiciFetch2(target, {
1453
+ // A direct probe must NOT carry the dispatcher, or undici would force it
1454
+ // through the proxy anyway.
1455
+ ...viaProxy ? { dispatcher: route.dispatcher } : {},
1254
1456
  signal: AbortSignal.timeout(TEST_TIMEOUT_MS)
1255
1457
  });
1256
1458
  const ms = Math.round(performance.now() - started);
1257
- if (response.status === 407) {
1258
- return { ok: false, ms, status: 407, kind: "proxy-auth", error: "\u4EE3\u7406\u8981\u6C42\u8BA4\u8BC1\uFF08HTTP 407\uFF09" };
1459
+ if (viaProxy && response.status === 407) {
1460
+ return { ok: false, ms, status: 407, kind: "proxy-auth", viaProxy, error: "\u4EE3\u7406\u8981\u6C42\u8BA4\u8BC1\uFF08HTTP 407\uFF09" };
1259
1461
  }
1260
1462
  let exitIp;
1261
1463
  try {
@@ -1263,15 +1465,92 @@ function apply(ctx, config) {
1263
1465
  if (typeof body?.ip === "string" && body.ip.length > 0) exitIp = body.ip;
1264
1466
  } catch {
1265
1467
  }
1468
+ const reachable = response.status < 500;
1469
+ if (reachable) {
1470
+ return {
1471
+ ok: true,
1472
+ ms,
1473
+ status: response.status,
1474
+ viaProxy,
1475
+ ...exitIp === void 0 ? {} : { exitIp },
1476
+ kind: "ok"
1477
+ };
1478
+ }
1266
1479
  return {
1267
- ok: response.ok === true,
1480
+ ok: false,
1268
1481
  ms,
1269
1482
  status: response.status,
1270
- ...exitIp === void 0 ? {} : { exitIp },
1271
- kind: response.ok === true ? "ok" : "target"
1483
+ viaProxy,
1484
+ kind: "target",
1485
+ error: `\u76EE\u6807\u8FD4\u56DE HTTP ${response.status}\uFF085xx\uFF0C\u4E0A\u6E38\u6216\u94FE\u8DEF\u5F02\u5E38\uFF09`
1272
1486
  };
1273
1487
  } catch (error) {
1274
- return { ok: false, ms: Math.round(performance.now() - started), ...classifyTestError(error) };
1488
+ return {
1489
+ ok: false,
1490
+ ms: Math.round(performance.now() - started),
1491
+ viaProxy,
1492
+ ...classifyTestError(error, viaProxy)
1493
+ };
1494
+ }
1495
+ };
1496
+ const selfCheckTargets = (provider, testTarget) => {
1497
+ const own = (testTarget ?? "").trim();
1498
+ if (own.length > 0) return [own];
1499
+ const upstream = upstreamOf(provider);
1500
+ if (upstream !== void 0) return [upstream];
1501
+ return plainTestUrls();
1502
+ };
1503
+ const selfCheckKey = (provider) => {
1504
+ const raw = plainProviders(config)[provider] ?? {};
1505
+ const endpoint = raw.useDefault === true ? plainDefaultProxy(config) : raw;
1506
+ return JSON.stringify([
1507
+ provider,
1508
+ endpoint.protocol ?? "http",
1509
+ endpoint.host ?? "",
1510
+ endpoint.port ?? 0,
1511
+ endpoint.username ?? "",
1512
+ endpoint.password ?? "",
1513
+ raw.testTarget ?? "",
1514
+ raw.onlyHosts ?? "",
1515
+ raw.bypassHosts ?? "",
1516
+ plainLocalDirect(config),
1517
+ selfCheckTargets(provider, raw.testTarget)
1518
+ ]);
1519
+ };
1520
+ const runSelfCheck = async (provider) => {
1521
+ let route = current.get(provider);
1522
+ let temporary = false;
1523
+ if (route === void 0) {
1524
+ const problem = problems.get(provider);
1525
+ if (problem !== void 0) {
1526
+ return { ok: false, kind: "route-rejected", error: bareMessage(problem) };
1527
+ }
1528
+ const configured = plainProviders(config)[provider];
1529
+ if (configured === void 0 || configured.enabled === true) {
1530
+ return { ok: false, kind: "no-route", error: `provider "${provider}" \u672A\u542F\u7528\u4EE3\u7406` };
1531
+ }
1532
+ const built = buildRouteEntry(provider, configured, {
1533
+ defaultProxy: normalizeDefaultProxy(plainDefaultProxy(config)),
1534
+ requireEnabled: false,
1535
+ localDirect: plainLocalDirect(config)
1536
+ });
1537
+ if (!built.ok) {
1538
+ return { ok: false, kind: "route-rejected", error: bareMessage(built.problem) };
1539
+ }
1540
+ route = built.entry;
1541
+ temporary = true;
1542
+ }
1543
+ try {
1544
+ let last;
1545
+ for (const target of selfCheckTargets(provider, route.testTarget)) {
1546
+ const outcome = await probeTarget(target, route);
1547
+ if (outcome.ok === true) return outcome;
1548
+ last = outcome;
1549
+ if (outcome.kind === "proxy-auth") break;
1550
+ }
1551
+ return last ?? { ok: false, kind: "target", error: "\u6CA1\u6709\u53EF\u7528\u7684\u81EA\u68C0\u76EE\u6807" };
1552
+ } finally {
1553
+ if (temporary) await route.dispatcher.close().catch(() => void 0);
1275
1554
  }
1276
1555
  };
1277
1556
  try {
@@ -1279,7 +1558,10 @@ function apply(ctx, config) {
1279
1558
  } catch {
1280
1559
  }
1281
1560
  const rebuild = async () => {
1282
- const next = createProxyTable(plainProviders(config));
1561
+ const next = createProxyTable(plainProviders(config), {
1562
+ defaultProxy: plainDefaultProxy(config),
1563
+ localDirect: plainLocalDirect(config)
1564
+ });
1283
1565
  const previous = current;
1284
1566
  current = next.entries;
1285
1567
  problems = next.problems;
@@ -1331,7 +1613,8 @@ function apply(ctx, config) {
1331
1613
  problems: Object.fromEntries(
1332
1614
  [...problems].map(([id, message]) => [id, bareMessage(message)])
1333
1615
  ),
1334
- hits: Object.fromEntries(hits)
1616
+ hits: Object.fromEntries(hits),
1617
+ lastUse: Object.fromEntries(lastUse)
1335
1618
  });
1336
1619
  res.writeHead(200, {
1337
1620
  "content-type": "application/json; charset=utf-8",
@@ -1400,12 +1683,21 @@ function apply(ctx, config) {
1400
1683
  send(400, { ok: false, kind: "body", error: "\u7F3A\u5C11 provider" });
1401
1684
  return;
1402
1685
  }
1403
- let pending = selfChecks.get(provider);
1686
+ const key = selfCheckKey(provider);
1687
+ let pending = selfChecks.get(key);
1404
1688
  if (pending === void 0) {
1405
- pending = runSelfCheck(provider);
1406
- selfChecks.set(provider, pending);
1689
+ const last = lastChecks.get(key);
1690
+ if (last !== void 0 && Date.now() - last.at < SELF_CHECK_COOLDOWN_MS) {
1691
+ send(200, { ...last.result, cached: true });
1692
+ return;
1693
+ }
1694
+ pending = runSelfCheck(provider).then((result) => {
1695
+ lastChecks.set(key, { at: Date.now(), result });
1696
+ return result;
1697
+ });
1698
+ selfChecks.set(key, pending);
1407
1699
  void pending.finally(() => {
1408
- if (selfChecks.get(provider) === pending) selfChecks.delete(provider);
1700
+ if (selfChecks.get(key) === pending) selfChecks.delete(key);
1409
1701
  });
1410
1702
  }
1411
1703
  send(200, await pending);
@@ -1431,6 +1723,7 @@ function apply(ctx, config) {
1431
1723
  {
1432
1724
  dispatcher: route.dispatcher,
1433
1725
  onHit: () => bump(WEB_SEARCH_KEY),
1726
+ onSettle: settle(WEB_SEARCH_KEY),
1434
1727
  decide: route.decide
1435
1728
  },
1436
1729
  () => original.call(this, request, signal)
@@ -1454,6 +1747,7 @@ function apply(ctx, config) {
1454
1747
  {
1455
1748
  dispatcher: route.dispatcher,
1456
1749
  onHit: () => bump(provider),
1750
+ onSettle: settle(provider),
1457
1751
  decide: route.decide
1458
1752
  },
1459
1753
  next
@@ -1483,7 +1777,8 @@ function apply(ctx, config) {
1483
1777
  proxyOrigin: entry.proxyOrigin
1484
1778
  })),
1485
1779
  problems: Object.fromEntries(problems),
1486
- hits: Object.fromEntries(hits)
1780
+ hits: Object.fromEntries(hits),
1781
+ lastUse: Object.fromEntries(lastUse)
1487
1782
  })
1488
1783
  });
1489
1784
  }
@@ -1494,10 +1789,13 @@ export {
1494
1789
  WEB_SEARCH_KEY,
1495
1790
  acquireFetchRouter,
1496
1791
  apply,
1792
+ buildRouteEntry,
1497
1793
  closeTable,
1498
1794
  createProxyTable,
1795
+ defaultProxyProblem,
1499
1796
  inject,
1500
1797
  name,
1798
+ normalizeDefaultProxy,
1501
1799
  normalizeRoute,
1502
1800
  proxyUrl
1503
1801
  };