mslxdff 0.1.220 → 0.1.222
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/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "mslxdff",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.222",
|
|
4
4
|
"description": "测试项目,请勿使用。",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -9,6 +9,8 @@
|
|
|
9
9
|
"scripts": {
|
|
10
10
|
"start": "node bin/mslxdff.js",
|
|
11
11
|
"test": "node --test --test-concurrency=1 test/*.test.js",
|
|
12
|
+
"test:scoped": "node scripts/test-scoped.js",
|
|
13
|
+
"test:all": "node --test --test-concurrency=1 test/*.test.js",
|
|
12
14
|
"docs:check": "node scripts/docs-check.js"
|
|
13
15
|
},
|
|
14
16
|
"engines": {
|
|
@@ -62,6 +62,10 @@ export async function handleProviderModels(id, sub, args, rest) {
|
|
|
62
62
|
} else if (id === "raccoon") {
|
|
63
63
|
const { createRaccoonProvider } = await import("../../../providers/raccoon/index.js");
|
|
64
64
|
provider = createRaccoonProvider({ id, apiKeys: keys, file: defaultStateFile() });
|
|
65
|
+
} else if (id === "railway") {
|
|
66
|
+
// 免凭证型:无可执行端点差异,但必须用定制工厂(generic 会把付费模型一起列出来)
|
|
67
|
+
const { createRailwayProvider } = await import("../../../providers/railway.js");
|
|
68
|
+
provider = createRailwayProvider({ baseUrl: baseUrl || undefined, file: defaultStateFile() });
|
|
65
69
|
} else {
|
|
66
70
|
if (!baseUrl) {
|
|
67
71
|
console.error(`provider ${id}: missing baseUrl — set via: mslxdff -provider ${id} set-url <baseUrl>`);
|
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
// Railway 定制供应商:opencode zen free 层的**另一个 IP 出口**(Railway 边缘反代 opencode.ai)。
|
|
2
|
+
// 本质:同一套 zen 契约,只是出口 IP 不同 ⇒ 免费额度按出口 IP 计,等于多一份额度。
|
|
3
|
+
//
|
|
4
|
+
// 与内置 opencode 的区别:
|
|
5
|
+
// ① 只暴露 free 模型(上游 /models 会把付费模型一起列出来,这里过滤掉);
|
|
6
|
+
// ② 每个请求强制补 free-lane 形状(stream:true + 五个核心工具名),否则 403 FreeTierError;
|
|
7
|
+
// ③ 带 opencode 身份头(UA + client/session/request/project),否则同样 403。
|
|
8
|
+
//
|
|
9
|
+
// 端点:<base>/zen/v1/models、<base>/zen/v1/chat/completions(base 缺省为 Railway 反代根)。
|
|
10
|
+
import { createKeyRing } from "./keyring.js";
|
|
11
|
+
import { collectApiKeysGeneric, envInt, createAgent, getUndici, joinUrl } from "./base.js";
|
|
12
|
+
import { compatFetch } from "../compat.js";
|
|
13
|
+
import { loadProviderBaseUrl, loadProviderKeys, saveProviderConfig } from "../state.js";
|
|
14
|
+
import { ensureFreeLaneShape, aggregateChatSse } from "../free-lane.js";
|
|
15
|
+
import { genId, opencodeUa } from "../opencode-identity.js";
|
|
16
|
+
import { joinModelId } from "./model-id.js";
|
|
17
|
+
|
|
18
|
+
const { UndiciFetch } = getUndici();
|
|
19
|
+
|
|
20
|
+
export const DEFAULT_RAILWAY_BASE = "https://mytestip-production.up.railway.app/ai-api";
|
|
21
|
+
export const MODELS_PATH = "/zen/v1/models";
|
|
22
|
+
export const CHAT_PATH = "/zen/v1/chat/completions";
|
|
23
|
+
|
|
24
|
+
// free 模型判定:上游用 `-free` 后缀标识免费档(如 deepseek-v4.1-flash-free)。
|
|
25
|
+
// `big-pickle`/`beam` 这类无后缀但确属免费档的也列出(它们是 zen 的免费模型且不能被付费调用)。
|
|
26
|
+
const FREE_SUFFIX = /-free$/i;
|
|
27
|
+
const FREE_EXTRA = new Set(["big-pickle", "beam"]);
|
|
28
|
+
|
|
29
|
+
export function isFreeModelId(id) {
|
|
30
|
+
const s = String(id || "").trim();
|
|
31
|
+
if (!s) return false;
|
|
32
|
+
return FREE_SUFFIX.test(s) || FREE_EXTRA.has(s.toLowerCase());
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function filterFreeModels(list) {
|
|
36
|
+
return (Array.isArray(list) ? list : []).filter((m) => m && isFreeModelId(m.id));
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function resolveBase(baseUrl) {
|
|
40
|
+
if (baseUrl) return String(baseUrl).trim().replace(/\/+$/, "");
|
|
41
|
+
const env = loadProviderBaseUrl("railway");
|
|
42
|
+
return env || DEFAULT_RAILWAY_BASE;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function createRailwayProvider({
|
|
46
|
+
id = "railway",
|
|
47
|
+
baseUrl,
|
|
48
|
+
apiKeys,
|
|
49
|
+
apiKey,
|
|
50
|
+
connectTimeoutMs = envInt("MSLXDFF_RAILWAY_TIMEOUT_MS", 60_000),
|
|
51
|
+
cooldownMs = envInt("MSLXDFF_RAILWAY_COOLDOWN_MS", 30_000),
|
|
52
|
+
fetchImpl,
|
|
53
|
+
file,
|
|
54
|
+
clock = Date.now,
|
|
55
|
+
} = {}) {
|
|
56
|
+
if (id !== "railway") throw new Error(`railway provider id mismatch: ${id}`);
|
|
57
|
+
const resolvedBase = resolveBase(baseUrl);
|
|
58
|
+
if (!fetchImpl) fetchImpl = UndiciFetch || compatFetch;
|
|
59
|
+
|
|
60
|
+
const rawKeys = collectApiKeysGeneric(id, apiKeys, apiKey, (pid) => loadProviderKeys(pid, file ? { file } : {}));
|
|
61
|
+
const allKeys = [...new Set(rawKeys.map((k) => String(k).trim()).filter(Boolean))];
|
|
62
|
+
const ring = createKeyRing(allKeys, { cooldownMs });
|
|
63
|
+
|
|
64
|
+
let agent = null; let dispatcher = null;
|
|
65
|
+
try {
|
|
66
|
+
const a = createAgent({
|
|
67
|
+
keepAliveTimeout: envInt("MSLXDFF_RAILWAY_KEEPALIVE_TIMEOUT", 30_000),
|
|
68
|
+
keepAliveMaxTimeout: envInt("MSLXDFF_RAILWAY_KEEPALIVE_MAX_TIMEOUT", 60_000),
|
|
69
|
+
connections: envInt("MSLXDFF_RAILWAY_KEEPALIVE_CONNECTIONS", 20),
|
|
70
|
+
});
|
|
71
|
+
agent = a.agent; dispatcher = a.dispatcher;
|
|
72
|
+
} catch {}
|
|
73
|
+
|
|
74
|
+
// 身份头 + free-lane:railway 反代的是 zen,两样缺一不可(缺任一都是 403 FreeTierError)。
|
|
75
|
+
function buildHeaders(opts = {}) {
|
|
76
|
+
return {
|
|
77
|
+
"Content-Type": "application/json",
|
|
78
|
+
"Accept": "text/event-stream",
|
|
79
|
+
"User-Agent": opencodeUa(),
|
|
80
|
+
"x-opencode-client": "desktop",
|
|
81
|
+
// session 复用调用方给的会话 id(同会话粘同一上游会话),没有才现生成
|
|
82
|
+
"x-opencode-session": opts.sessionId ? `ses_${String(opts.sessionId).slice(0, 24)}` : genId("ses_"),
|
|
83
|
+
"x-opencode-request": genId("msg_"),
|
|
84
|
+
"x-opencode-project": "global",
|
|
85
|
+
...(ring.size ? {} : {}),
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
async function chat(body, opts = {}) {
|
|
90
|
+
const wantStream = body?.stream === true;
|
|
91
|
+
// free-lane 强制 stream:true:非流式请求在这里补形状,聚合回 JSON 后再返回。
|
|
92
|
+
const outBody = { ...body, stream: true };
|
|
93
|
+
ensureFreeLaneShape(outBody);
|
|
94
|
+
const headers = buildHeaders(opts);
|
|
95
|
+
const key = ring.size ? ring.next() : null;
|
|
96
|
+
if (key) headers.Authorization = `Bearer ${key}`;
|
|
97
|
+
|
|
98
|
+
const url = joinUrl(resolvedBase, CHAT_PATH);
|
|
99
|
+
const ac = new AbortController();
|
|
100
|
+
const timer = setTimeout(() => ac.abort(new Error(`railway timed out after ${connectTimeoutMs}ms`)), connectTimeoutMs);
|
|
101
|
+
let res;
|
|
102
|
+
try {
|
|
103
|
+
res = await fetchImpl(url, {
|
|
104
|
+
method: "POST",
|
|
105
|
+
headers,
|
|
106
|
+
body: JSON.stringify(outBody),
|
|
107
|
+
signal: ac.signal,
|
|
108
|
+
...(dispatcher ? { dispatcher } : {}),
|
|
109
|
+
});
|
|
110
|
+
} catch (err) {
|
|
111
|
+
clearTimeout(timer);
|
|
112
|
+
if (key) ring.onError(key);
|
|
113
|
+
throw err;
|
|
114
|
+
}
|
|
115
|
+
clearTimeout(timer);
|
|
116
|
+
|
|
117
|
+
if (res.status >= 400) {
|
|
118
|
+
if (key) ring.onError(key);
|
|
119
|
+
return res;
|
|
120
|
+
}
|
|
121
|
+
if (!wantStream) {
|
|
122
|
+
// 客户端要非流式:把上游 SSE 聚合回 chat.completion JSON
|
|
123
|
+
return aggregateChatSse(res);
|
|
124
|
+
}
|
|
125
|
+
return res;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
let modelsCache = { at: 0, list: [] };
|
|
129
|
+
const MODELS_TTL_MS = 10 * 60 * 1000;
|
|
130
|
+
|
|
131
|
+
async function fetchModels() {
|
|
132
|
+
const key = ring.size ? ring.next() : null;
|
|
133
|
+
const headers = { "Accept": "application/json", "User-Agent": opencodeUa() };
|
|
134
|
+
if (key) headers.Authorization = `Bearer ${key}`;
|
|
135
|
+
const res = await fetchImpl(joinUrl(resolvedBase, MODELS_PATH), {
|
|
136
|
+
headers,
|
|
137
|
+
...(dispatcher ? { dispatcher } : {}),
|
|
138
|
+
});
|
|
139
|
+
if (!res.ok) throw new Error(`railway models: http ${res.status}`);
|
|
140
|
+
const j = await res.json();
|
|
141
|
+
return filterFreeModels(j?.data);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
async function listModels() {
|
|
145
|
+
if (clock() - modelsCache.at < MODELS_TTL_MS && modelsCache.list.length) return modelsCache.list;
|
|
146
|
+
let raw = [];
|
|
147
|
+
try {
|
|
148
|
+
raw = await fetchModels();
|
|
149
|
+
} catch {
|
|
150
|
+
return modelsCache.list; // 拉取失败:给上一次的缓存(首次则空表),不阻断启动
|
|
151
|
+
}
|
|
152
|
+
const list = raw.map((m) => ({
|
|
153
|
+
id: joinModelId(id, m.id),
|
|
154
|
+
object: "model",
|
|
155
|
+
name: m.name || m.id,
|
|
156
|
+
owned_by: m.owned_by || "opencode",
|
|
157
|
+
...(m.created ? { created: m.created } : {}),
|
|
158
|
+
}));
|
|
159
|
+
modelsCache = { at: clock(), list };
|
|
160
|
+
return list;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
async function preheat() {
|
|
164
|
+
try {
|
|
165
|
+
const m = await listModels();
|
|
166
|
+
return { ok: m.length > 0, models: m.length };
|
|
167
|
+
} catch (err) {
|
|
168
|
+
return { ok: false, error: String(err?.message || err).slice(0, 120) };
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
async function close() { if (agent?.close) try { await agent.close(); } catch {} }
|
|
173
|
+
|
|
174
|
+
return {
|
|
175
|
+
id,
|
|
176
|
+
chat,
|
|
177
|
+
listModels,
|
|
178
|
+
preheat,
|
|
179
|
+
close,
|
|
180
|
+
agent,
|
|
181
|
+
keyRing: ring,
|
|
182
|
+
baseUrl: resolvedBase,
|
|
183
|
+
_isFreeModelId: isFreeModelId,
|
|
184
|
+
_filterFreeModels: filterFreeModels,
|
|
185
|
+
};
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
// 配置写回(-provider railway set-url 等走通用 CLI,这里提供与其它定制供应商一致的落盘形状)
|
|
189
|
+
export function saveRailwayConfig({ baseUrl, keys } = {}, { file } = {}) {
|
|
190
|
+
const cur = {
|
|
191
|
+
baseUrl: loadProviderBaseUrl("railway", file ? { file } : {}) || DEFAULT_RAILWAY_BASE,
|
|
192
|
+
keys: loadProviderKeys("railway", file ? { file } : {}),
|
|
193
|
+
};
|
|
194
|
+
saveProviderConfig("railway", {
|
|
195
|
+
baseUrl: baseUrl ?? cur.baseUrl,
|
|
196
|
+
keys: keys ?? cur.keys,
|
|
197
|
+
}, file ? { file } : {});
|
|
198
|
+
}
|
|
@@ -48,6 +48,13 @@ export const customProviders = [
|
|
|
48
48
|
match: (id, baseUrl) => id === "zcode" || String(baseUrl).includes("zcode.z.ai"),
|
|
49
49
|
load: () => import("./zcode/index.js").then((m) => m.createZcodeProvider),
|
|
50
50
|
},
|
|
51
|
+
{
|
|
52
|
+
id: "railway",
|
|
53
|
+
// opencode zen free 层的另一个 IP 出口(Railway 反代),只暴露 free 模型。
|
|
54
|
+
// 匹配必须排在 generic 之前,且不能与其它条目撞(railway.app 域名足够独特)。
|
|
55
|
+
match: (id, baseUrl) => id === "railway" || String(baseUrl).includes("railway.app"),
|
|
56
|
+
load: () => import("./railway.js").then((m) => m.createRailwayProvider),
|
|
57
|
+
},
|
|
51
58
|
{
|
|
52
59
|
id: "raccoon",
|
|
53
60
|
match: (id, baseUrl) => id === "raccoon" || String(baseUrl).includes("xiaohuanxiong.com"),
|
|
@@ -7,17 +7,24 @@
|
|
|
7
7
|
export const AUTH_DOC_PROVIDER_IDS = ["workbuddy", "traework", "qoder", "qwenwork", "globalqwenwork", "raccoon"];
|
|
8
8
|
|
|
9
9
|
/**
|
|
10
|
+
* 免凭证供应商:上游就是 opencode zen 的免费层代理,没有 key 也能用(按出口 IP 计额度)。
|
|
11
|
+
* 与 auth 号型不同:它连账号都不需要,只要在 state.providerConfigs 里有一条记录就启用。
|
|
12
|
+
*/
|
|
13
|
+
export const NO_CREDENTIAL_PROVIDER_IDS = ["railway"];
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* - 免凭证型(railway):有配置记录即启用(无需 keys/baseUrl)
|
|
10
17
|
* - auth 号型:keys 或 auths 或 auth 目录有号 → 启用(baseUrl 可空)
|
|
11
18
|
* - 其他定制(cline/codearts/插件注册):有 keys → 启用(baseUrl 可空,端点由工厂决定)
|
|
12
19
|
*/
|
|
13
20
|
export function shouldEnableCustomProvider(gid, { keys = [], auths = [], hasAuthDocs = false } = {}) {
|
|
21
|
+
if (NO_CREDENTIAL_PROVIDER_IDS.includes(gid)) return true;
|
|
14
22
|
if (AUTH_DOC_PROVIDER_IDS.includes(gid)) return keys.length > 0 || auths.length > 0 || hasAuthDocs;
|
|
15
23
|
return keys.length > 0;
|
|
16
24
|
}
|
|
17
25
|
|
|
18
26
|
/** 按 gid 载入各自 account-store 并列出落盘账号(失败/无目录 → 空数组,不抛)。 */
|
|
19
|
-
export async function loadAuthDocs(gid) {
|
|
20
|
-
try {
|
|
27
|
+
export async function loadAuthDocs(gid) { try {
|
|
21
28
|
const store = gid === "workbuddy"
|
|
22
29
|
? await import("../providers/workbuddy/account-store.js")
|
|
23
30
|
: gid === "traework"
|
|
@@ -34,3 +41,35 @@ export async function loadAuthDocs(gid) {
|
|
|
34
41
|
return [];
|
|
35
42
|
}
|
|
36
43
|
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* 免凭证供应商的配置种子:providers-setup 只遍历 state.providerConfigs 里已有的条目,
|
|
47
|
+
* 没有记录就永远不会被装配。这里幂等补一条(已有则不动,保留用户改过的 baseUrl)。
|
|
48
|
+
* 关闭:设 MSLXDFF_RAILWAY=0。
|
|
49
|
+
*
|
|
50
|
+
* saveConfig(gid, cfg) 由调用方注入,需自行处理两件事:
|
|
51
|
+
* ① 非空 baseUrl —— 否则 saveProviderConfig 见「无 url/key/白名单」会整条 delete;
|
|
52
|
+
* ② allowAnyModels —— 否则 /v1/models 按「空白名单=不暴露」的安全默认,一个模型都列不出。
|
|
53
|
+
*/
|
|
54
|
+
export async function seedNoCredentialProviders({ configs, saveConfig } = {}) {
|
|
55
|
+
if (!configs || typeof saveConfig !== "function") return [];
|
|
56
|
+
const seeded = [];
|
|
57
|
+
for (const gid of NO_CREDENTIAL_PROVIDER_IDS) {
|
|
58
|
+
if (configs[gid]) continue; // 已有配置:尊重用户设置
|
|
59
|
+
if (gid === "railway") {
|
|
60
|
+
if (isDisabled("MSLXDFF_RAILWAY")) continue;
|
|
61
|
+
await saveConfig(gid, { baseUrl: RAILWAY_DEFAULT_BASE, keys: [], allowAnyModels: true });
|
|
62
|
+
seeded.push(gid);
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
return seeded;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const RAILWAY_DEFAULT_BASE = "https://mytestip-production.up.railway.app/ai-api";
|
|
69
|
+
|
|
70
|
+
function isDisabled(envName) {
|
|
71
|
+
const raw = process.env[envName];
|
|
72
|
+
if (raw === undefined || raw === null || raw === "") return false;
|
|
73
|
+
const s = String(raw).trim().toLowerCase();
|
|
74
|
+
return s === "0" || s === "false" || s === "off" || s === "no";
|
|
75
|
+
}
|
|
@@ -10,13 +10,13 @@ import { createGroupsService, createBansService } from "../groups.js";
|
|
|
10
10
|
import { logDir, appendEvent } from "../logs.js";
|
|
11
11
|
import { loadPlugins, runHook, resolvePluginDirs } from "../plugins.js";
|
|
12
12
|
import { createOpenCodeProvider } from "../providers/opencode.js";
|
|
13
|
-
import { loadProviderKeys, loadProviderAuths, loadProviderConfigs } from "../state.js";
|
|
13
|
+
import { loadProviderKeys, loadProviderAuths, loadProviderConfigs, saveProviderConfig } from "../state.js";
|
|
14
14
|
import { refreshIntervalMs, modelCooldownMs, slowCooldownMs, peerCooldownMs, peerLimitCooldownMs, peerHeatMs, banWindowMs, banThreshold } from "../cli/policy.js";
|
|
15
15
|
import { errMsg } from "../cli/util.js";
|
|
16
16
|
import { createPreferredDispatcher } from "../v6pool/index.js";
|
|
17
17
|
|
|
18
18
|
|
|
19
|
-
import { shouldEnableCustomProvider, loadAuthDocs } from "./provider-gate.js";
|
|
19
|
+
import { shouldEnableCustomProvider, loadAuthDocs, seedNoCredentialProviders } from "./provider-gate.js";
|
|
20
20
|
/**
|
|
21
21
|
* 世界组装 — 插件加载 → providers → models/auto/peers/groups/bans。
|
|
22
22
|
* 无参(读 env/state 自身),返回 ctx 由门面接力 server-lifecycle。
|
|
@@ -81,6 +81,25 @@ export async function setupProviders() {
|
|
|
81
81
|
appendEvent({ ts: Date.now(), type: "provider-enabled", provider: "openrouter", keys: orKeys.length });
|
|
82
82
|
}
|
|
83
83
|
const genericConfigs = loadProviderConfigs();
|
|
84
|
+
// 免凭证供应商(railway):没有配置记录就不会进下面的循环,先幂等补种一条。
|
|
85
|
+
try {
|
|
86
|
+
const seeded = await seedNoCredentialProviders({
|
|
87
|
+
configs: genericConfigs,
|
|
88
|
+
saveConfig: async (gid, cfg) => {
|
|
89
|
+
genericConfigs[gid] = cfg;
|
|
90
|
+
// 两步写:saveProviderConfig 不解构 allowAnyModels,得用专门的 setter 补,
|
|
91
|
+
// 否则 /v1/models 按「空白名单 = 不暴露」的安全默认,railway 一个模型都列不出来。
|
|
92
|
+
saveProviderConfig(gid, { baseUrl: cfg.baseUrl, keys: cfg.keys || [] });
|
|
93
|
+
if (cfg.allowAnyModels !== undefined) {
|
|
94
|
+
const { saveProviderAllowAnyModels } = await import("../state.js");
|
|
95
|
+
saveProviderAllowAnyModels(gid, cfg.allowAnyModels);
|
|
96
|
+
}
|
|
97
|
+
},
|
|
98
|
+
});
|
|
99
|
+
if (seeded.length) console.log(`provider seeded (no credential): ${seeded.join(", ")}`);
|
|
100
|
+
} catch (err) {
|
|
101
|
+
console.log(`provider seed failed: ${errMsg(err)}`);
|
|
102
|
+
}
|
|
84
103
|
for (const [gid, cfg] of Object.entries(genericConfigs)) {
|
|
85
104
|
if (gid === "opencode" || gid === "openrouter") continue;
|
|
86
105
|
const base = String(cfg?.baseUrl || "").trim();
|