ellm-proxy 0.0.3 → 0.0.7

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 (58) hide show
  1. package/README.md +60 -60
  2. package/bin/commands/autostart.js +37 -213
  3. package/bin/commands/config.js +8 -8
  4. package/bin/commands/credentials.js +100 -0
  5. package/bin/commands/pm2.js +28 -28
  6. package/bin/commands/service.js +107 -107
  7. package/bin/ellm-proxy.js +64 -61
  8. package/dist/server.js +141 -4
  9. package/dist/web-ui/assets/DashboardView-BCwrFrE2.js +1 -0
  10. package/dist/web-ui/assets/DashboardView-BHG4xIdN.css +1 -0
  11. package/dist/web-ui/assets/LayoutView-BUA8-IzK.css +1 -0
  12. package/dist/web-ui/assets/LayoutView-BePJfgia.js +1 -0
  13. package/dist/web-ui/assets/LoginView-Dp7NmXtf.js +1 -0
  14. package/dist/web-ui/assets/LoginView-DrhfslIY.css +1 -0
  15. package/dist/web-ui/assets/{LogsView-B2ctrpv4.js → LogsView-Dyy_zkgg.js} +2 -2
  16. package/dist/web-ui/assets/ProcessView-CpPpMtFg.js +1 -0
  17. package/dist/web-ui/assets/ProcessView-DK0TvDpR.css +1 -0
  18. package/dist/web-ui/assets/{ProvidersView-mb0KU16n.js → ProvidersView-CYFs76wJ.js} +2 -2
  19. package/dist/web-ui/assets/SettingsView-BkwyoOUb.css +1 -0
  20. package/dist/web-ui/assets/SettingsView-mPcaVAD2.js +1 -0
  21. package/dist/web-ui/assets/SetupView-DX6S_KpK.js +1 -0
  22. package/dist/web-ui/assets/SetupView-kPJXJ7dX.css +1 -0
  23. package/dist/web-ui/assets/config-CRWaUFQc.js +1 -0
  24. package/dist/web-ui/assets/{index-dPeMJjrz.js → index-X5PdkJlA.js} +28 -28
  25. package/dist/web-ui/assets/{providers-Dmz2p4Wd.js → providers-H_9x778D.js} +1 -1
  26. package/dist/web-ui/assets/useManagerApi-C1_COxKg.js +1 -0
  27. package/dist/web-ui/assets/{useToast-B3quFZyv.js → useToast-MtbkGJiM.js} +1 -1
  28. package/dist/web-ui/config.js +10 -10
  29. package/dist/web-ui/index.html +13 -13
  30. package/docs/DESIGN.md +99 -99
  31. package/ellm.config.example.json +2 -0
  32. package/manager/api/harness/backup.js +20 -20
  33. package/manager/api/harness/claude-code.js +54 -54
  34. package/manager/api/harness/codex.js +123 -123
  35. package/manager/api/harness/dsh.js +175 -175
  36. package/manager/api/harness/index.js +118 -118
  37. package/manager/api/service-settings.js +98 -0
  38. package/manager/api/service.js +3 -13
  39. package/manager/index.js +182 -170
  40. package/package.json +4 -1
  41. package/utils/autostart.js +219 -0
  42. package/utils/interpreter.js +14 -14
  43. package/utils/paths.js +45 -44
  44. package/utils/pm2.js +130 -130
  45. package/utils/service.js +205 -189
  46. package/utils/updater.js +233 -0
  47. package/dist/web-ui/assets/DashboardView-B71Dy3px.js +0 -1
  48. package/dist/web-ui/assets/DashboardView-qrQrkgLA.css +0 -1
  49. package/dist/web-ui/assets/LayoutView-C03GVB3X.js +0 -1
  50. package/dist/web-ui/assets/LoginView-Dg0mB6ok.css +0 -1
  51. package/dist/web-ui/assets/LoginView-XTF5dHvd.js +0 -1
  52. package/dist/web-ui/assets/ProcessView-DmbVABQV.js +0 -1
  53. package/dist/web-ui/assets/ProcessView-DzMqBJmd.css +0 -1
  54. package/dist/web-ui/assets/SettingsView-BDkymsrS.css +0 -1
  55. package/dist/web-ui/assets/SettingsView-BgLQVC2H.js +0 -1
  56. package/dist/web-ui/assets/config-BRaXq0LL.js +0 -1
  57. package/dist/web-ui/assets/useApi-C1Ngt_o5.js +0 -1
  58. package/dist/web-ui/assets/useManagerApi-BDWJz5o6.js +0 -1
@@ -1,123 +1,123 @@
1
- // Codex 代理开关:~/.codex/config.toml 顶层 model_provider + [model_providers.ellm-proxy] 段
2
- // 指向本机 ellm-proxy 网关(语法见 openai/codex docs/config.md;wire_api 仅支持 chat|responses,
3
- // 此处用 chat 走 /v1/chat/completions)。
4
- // TOML 解析/校验用 smol-toml 库(不手写解析器);写入采用文本级编辑以保留用户注释与排版,
5
- // 且写盘前必须通过库校验(结果文本可解析且 model_provider 正确),否则抛错保留原文件。
6
- import { readFileSync, writeFileSync, existsSync, mkdirSync } from "node:fs";
7
- import { homedir } from "node:os";
8
- import { join, dirname } from "node:path";
9
- import { parse as parseToml } from "smol-toml";
10
- import { backupOnce, restoreFromBackup } from "./backup.js";
11
-
12
- const PROVIDER_ID = "ellm-proxy"; // Codex provider id(避开官方保留 id: openai/ollama/lmstudio)
13
- const CODEX_CONFIG = join(homedir(), ".codex", "config.toml");
14
-
15
- function readCodexConfig() {
16
- return existsSync(CODEX_CONFIG) ? readFileSync(CODEX_CONFIG, "utf-8") : null;
17
- }
18
-
19
- /** 是否已写顶层 model_provider = "ellm-proxy"(库解析;文本不合法按未开启处理,enable 时会给出明确报错) */
20
- function codexProviderActive(text) {
21
- try {
22
- return parseToml(text)?.model_provider === PROVIDER_ID;
23
- } catch {
24
- return false;
25
- }
26
- }
27
-
28
- /** 库校验:文本必须可解析,且 expectProvider 为 true 时顶层 model_provider 必须是本 provider(写盘前强制调用) */
29
- function validateTomlText(text, expectProvider) {
30
- let parsed;
31
- try {
32
- parsed = parseToml(text);
33
- } catch (err) {
34
- throw new Error(`编辑后的 config.toml 不是合法 TOML,已保留原文件: ${err.message}`);
35
- }
36
- if (expectProvider && parsed?.model_provider !== PROVIDER_ID) {
37
- throw new Error(`编辑后的 config.toml 顶层 model_provider 不是 "${PROVIDER_ID}",已保留原文件`);
38
- }
39
- }
40
-
41
- /** Codex 开关状态:顶层 model_provider 是否为 ellm-proxy */
42
- export function codexStatus() {
43
- const text = readCodexConfig();
44
- return text ? codexProviderActive(text) : false;
45
- }
46
-
47
- /** 移除已有的 [model_providers.ellm-proxy] 段(从段头到下一个段头/文件尾),供重写为最新 base_url */
48
- function removeProviderSection(text) {
49
- const lines = text.split("\n");
50
- const out = [];
51
- let skipping = false;
52
- for (const line of lines) {
53
- if (/^\s*\[model_providers\.ellm-proxy\]\s*$/.test(line)) {
54
- skipping = true;
55
- continue;
56
- }
57
- if (skipping && /^\s*\[/.test(line)) skipping = false; // 下一个段头,恢复收集
58
- if (!skipping) out.push(line);
59
- }
60
- return out.join("\n").replace(/\n{3,}$/, "\n\n");
61
- }
62
-
63
- /** 设置顶层 model_provider = "ellm-proxy":已有该键则替换(忽略注释行),否则插到首个段头前(TOML 顶层键必须在任何 [section] 之前) */
64
- function setTopLevelProvider(text) {
65
- const lines = text.split("\n");
66
- const keyLine = `model_provider = "${PROVIDER_ID}"`;
67
- let replaced = false;
68
- const out = lines.map((line) => {
69
- if (!replaced && /^\s*model_provider\s*=/.test(line) && !line.trim().startsWith("#")) {
70
- replaced = true;
71
- return keyLine;
72
- }
73
- return line;
74
- });
75
- if (replaced) return out.join("\n");
76
- const firstSection = out.findIndex((line) => /^\s*\[/.test(line));
77
- if (firstSection === -1) return [...out, keyLine].join("\n");
78
- out.splice(firstSection, 0, keyLine, "");
79
- return out.join("\n");
80
- }
81
-
82
- /** 开启 Codex 代理:备份 → 写 model_provider + [model_providers.ellm-proxy] 段(重复开启幂等刷新 base_url)。
83
- * 原文本不合法时直接报错(不覆盖坏文件);编辑结果写盘前经库校验 */
84
- export function codexEnable({ gatewayBase }) {
85
- mkdirSync(dirname(CODEX_CONFIG), { recursive: true });
86
- const original = readCodexConfig();
87
- if (original !== null) {
88
- try {
89
- parseToml(original);
90
- } catch (err) {
91
- throw new Error(`${CODEX_CONFIG} 不是合法 TOML,拒绝修改(请先手工修复): ${err.message}`);
92
- }
93
- }
94
- let text = original ?? "";
95
- backupOnce(CODEX_CONFIG);
96
- text = removeProviderSection(text);
97
- text = setTopLevelProvider(text);
98
- const section = [
99
- `[model_providers.${PROVIDER_ID}]`,
100
- `name = "ellm-proxy gateway"`,
101
- `base_url = "${gatewayBase}/v1"`,
102
- `wire_api = "chat"`,
103
- `env_key = "ELLM_PROXY_API_KEY"`,
104
- ].join("\n");
105
- text = text.replace(/\s*$/, "") + "\n\n" + section + "\n";
106
- validateTomlText(text, true);
107
- writeFileSync(CODEX_CONFIG, text, "utf-8");
108
- }
109
-
110
- /** 关闭 Codex 代理:从备份还原(无备份时移除 provider 段与顶层键,写盘前经库校验) */
111
- export function codexDisable() {
112
- if (restoreFromBackup(CODEX_CONFIG)) return true;
113
- const text = readCodexConfig();
114
- if (!text || !codexProviderActive(text)) return false;
115
- let out = removeProviderSection(text);
116
- out = out
117
- .split("\n")
118
- .filter((line) => !/^\s*model_provider\s*=\s*["']?ellm-proxy["']?\s*$/.test(line))
119
- .join("\n");
120
- validateTomlText(out, false);
121
- writeFileSync(CODEX_CONFIG, out, "utf-8");
122
- return true;
123
- }
1
+ // Codex 代理开关:~/.codex/config.toml 顶层 model_provider + [model_providers.ellm-proxy] 段
2
+ // 指向本机 ellm-proxy 网关(语法见 openai/codex docs/config.md;wire_api 仅支持 chat|responses,
3
+ // 此处用 chat 走 /v1/chat/completions)。
4
+ // TOML 解析/校验用 smol-toml 库(不手写解析器);写入采用文本级编辑以保留用户注释与排版,
5
+ // 且写盘前必须通过库校验(结果文本可解析且 model_provider 正确),否则抛错保留原文件。
6
+ import { readFileSync, writeFileSync, existsSync, mkdirSync } from "node:fs";
7
+ import { homedir } from "node:os";
8
+ import { join, dirname } from "node:path";
9
+ import { parse as parseToml } from "smol-toml";
10
+ import { backupOnce, restoreFromBackup } from "./backup.js";
11
+
12
+ const PROVIDER_ID = "ellm-proxy"; // Codex provider id(避开官方保留 id: openai/ollama/lmstudio)
13
+ const CODEX_CONFIG = join(homedir(), ".codex", "config.toml");
14
+
15
+ function readCodexConfig() {
16
+ return existsSync(CODEX_CONFIG) ? readFileSync(CODEX_CONFIG, "utf-8") : null;
17
+ }
18
+
19
+ /** 是否已写顶层 model_provider = "ellm-proxy"(库解析;文本不合法按未开启处理,enable 时会给出明确报错) */
20
+ function codexProviderActive(text) {
21
+ try {
22
+ return parseToml(text)?.model_provider === PROVIDER_ID;
23
+ } catch {
24
+ return false;
25
+ }
26
+ }
27
+
28
+ /** 库校验:文本必须可解析,且 expectProvider 为 true 时顶层 model_provider 必须是本 provider(写盘前强制调用) */
29
+ function validateTomlText(text, expectProvider) {
30
+ let parsed;
31
+ try {
32
+ parsed = parseToml(text);
33
+ } catch (err) {
34
+ throw new Error(`编辑后的 config.toml 不是合法 TOML,已保留原文件: ${err.message}`);
35
+ }
36
+ if (expectProvider && parsed?.model_provider !== PROVIDER_ID) {
37
+ throw new Error(`编辑后的 config.toml 顶层 model_provider 不是 "${PROVIDER_ID}",已保留原文件`);
38
+ }
39
+ }
40
+
41
+ /** Codex 开关状态:顶层 model_provider 是否为 ellm-proxy */
42
+ export function codexStatus() {
43
+ const text = readCodexConfig();
44
+ return text ? codexProviderActive(text) : false;
45
+ }
46
+
47
+ /** 移除已有的 [model_providers.ellm-proxy] 段(从段头到下一个段头/文件尾),供重写为最新 base_url */
48
+ function removeProviderSection(text) {
49
+ const lines = text.split("\n");
50
+ const out = [];
51
+ let skipping = false;
52
+ for (const line of lines) {
53
+ if (/^\s*\[model_providers\.ellm-proxy\]\s*$/.test(line)) {
54
+ skipping = true;
55
+ continue;
56
+ }
57
+ if (skipping && /^\s*\[/.test(line)) skipping = false; // 下一个段头,恢复收集
58
+ if (!skipping) out.push(line);
59
+ }
60
+ return out.join("\n").replace(/\n{3,}$/, "\n\n");
61
+ }
62
+
63
+ /** 设置顶层 model_provider = "ellm-proxy":已有该键则替换(忽略注释行),否则插到首个段头前(TOML 顶层键必须在任何 [section] 之前) */
64
+ function setTopLevelProvider(text) {
65
+ const lines = text.split("\n");
66
+ const keyLine = `model_provider = "${PROVIDER_ID}"`;
67
+ let replaced = false;
68
+ const out = lines.map((line) => {
69
+ if (!replaced && /^\s*model_provider\s*=/.test(line) && !line.trim().startsWith("#")) {
70
+ replaced = true;
71
+ return keyLine;
72
+ }
73
+ return line;
74
+ });
75
+ if (replaced) return out.join("\n");
76
+ const firstSection = out.findIndex((line) => /^\s*\[/.test(line));
77
+ if (firstSection === -1) return [...out, keyLine].join("\n");
78
+ out.splice(firstSection, 0, keyLine, "");
79
+ return out.join("\n");
80
+ }
81
+
82
+ /** 开启 Codex 代理:备份 → 写 model_provider + [model_providers.ellm-proxy] 段(重复开启幂等刷新 base_url)。
83
+ * 原文本不合法时直接报错(不覆盖坏文件);编辑结果写盘前经库校验 */
84
+ export function codexEnable({ gatewayBase }) {
85
+ mkdirSync(dirname(CODEX_CONFIG), { recursive: true });
86
+ const original = readCodexConfig();
87
+ if (original !== null) {
88
+ try {
89
+ parseToml(original);
90
+ } catch (err) {
91
+ throw new Error(`${CODEX_CONFIG} 不是合法 TOML,拒绝修改(请先手工修复): ${err.message}`);
92
+ }
93
+ }
94
+ let text = original ?? "";
95
+ backupOnce(CODEX_CONFIG);
96
+ text = removeProviderSection(text);
97
+ text = setTopLevelProvider(text);
98
+ const section = [
99
+ `[model_providers.${PROVIDER_ID}]`,
100
+ `name = "ellm-proxy gateway"`,
101
+ `base_url = "${gatewayBase}/v1"`,
102
+ `wire_api = "chat"`,
103
+ `env_key = "ELLM_PROXY_API_KEY"`,
104
+ ].join("\n");
105
+ text = text.replace(/\s*$/, "") + "\n\n" + section + "\n";
106
+ validateTomlText(text, true);
107
+ writeFileSync(CODEX_CONFIG, text, "utf-8");
108
+ }
109
+
110
+ /** 关闭 Codex 代理:从备份还原(无备份时移除 provider 段与顶层键,写盘前经库校验) */
111
+ export function codexDisable() {
112
+ if (restoreFromBackup(CODEX_CONFIG)) return true;
113
+ const text = readCodexConfig();
114
+ if (!text || !codexProviderActive(text)) return false;
115
+ let out = removeProviderSection(text);
116
+ out = out
117
+ .split("\n")
118
+ .filter((line) => !/^\s*model_provider\s*=\s*["']?ellm-proxy["']?\s*$/.test(line))
119
+ .join("\n");
120
+ validateTomlText(out, false);
121
+ writeFileSync(CODEX_CONFIG, out, "utf-8");
122
+ return true;
123
+ }
@@ -1,175 +1,175 @@
1
- // DeepSeek Harness(dsh)代理开关:~/.dsh/settings.yaml 的 llm-deepseek.baseURL 指向本机网关,
2
- // 密钥写入官方托管凭据库 ~/.dsh/.credentials.yaml(refs.DEEPSEEK_API_KEY)。
3
- // dsh 解析口径(0.1.x developer preview):llm-deepseek 适配器拼 {baseURL}/chat/completions,
4
- // 故 baseURL 需带 /v1;密钥解析优先级 = 进程环境变量 > .credentials.yaml > .env(进程已设
5
- // DEEPSEEK_API_KEY 时本开关写入的值不生效,需用户自行调整)。DSH_HOME env 可重定向 ~/.dsh。
6
- // YAML 解析/校验用 yaml 库 Document API(保留用户注释),不手写解析器;写盘前经库回读校验。
7
- import { readFileSync, writeFileSync, existsSync, mkdirSync } from "node:fs";
8
- import { homedir } from "node:os";
9
- import { join, dirname } from "node:path";
10
- import { parseDocument, isMap } from "yaml";
11
- import { backupOnce, restoreFromBackup } from "./backup.js";
12
-
13
- /** dsh 密钥引用名(llm-deepseek 的 apiKeyEnv 默认值) */
14
- const KEY_REF = "DEEPSEEK_API_KEY";
15
- /** 凭据文档 schema 版本(dsh credentials-local 的 version: 1) */
16
- const CREDENTIALS_VERSION = 1;
17
- /** 网关未配 masterKey 时写入的占位密钥(值任意:网关未设 masterKey 时放行,但 dsh 本地要求非空) */
18
- const PLACEHOLDER_KEY = "ellm-proxy";
19
-
20
- const dshHome = () => process.env.DSH_HOME || join(homedir(), ".dsh");
21
- const settingsFile = () => join(dshHome(), "settings.yaml");
22
- const credentialsFile = () => join(dshHome(), ".credentials.yaml");
23
-
24
- /** 去尾斜杠比较,容忍 http://x:1/ 与 http://x:1 视为同一地址 */
25
- function sameUrl(a, b) {
26
- return String(a || "").replace(/\/+$/, "") === String(b || "").replace(/\/+$/, "");
27
- }
28
-
29
- function readTextIfExists(file) {
30
- if (!existsSync(file)) return null;
31
- const text = readFileSync(file, "utf-8");
32
- return text.trim() === "" ? null : text; // 空文件按不存在处理(dsh:absent = empty store)
33
- }
34
-
35
- /** 解析 YAML 文档(不合法抛错,保留原文件语义同 claude-code 的 JSON 处理) */
36
- function parseYamlDoc(text, file) {
37
- const doc = parseDocument(text);
38
- if (doc.errors.length > 0) {
39
- throw new Error(`${file} 不是合法 YAML,拒绝修改(请先手工修复): ${doc.errors[0].message}`);
40
- }
41
- return doc;
42
- }
43
-
44
- /** 开关目标端点:dsh 适配器在 baseURL 后拼 /chat/completions,故带 /v1 走网关正式路由 */
45
- function targetBase(gatewayBase) {
46
- return `${gatewayBase}/v1`;
47
- }
48
-
49
- /** dsh 开关状态:settings.yaml 的 llm-deepseek.baseURL 是否已指向网关(文件缺失/不合法按未开启) */
50
- export function dshStatus(gatewayBase) {
51
- const text = readTextIfExists(settingsFile());
52
- if (!text) return false;
53
- try {
54
- const data = parseYamlDoc(text, settingsFile()).toJS();
55
- return sameUrl(data?.["llm-deepseek"]?.baseURL, targetBase(gatewayBase));
56
- } catch {
57
- return false;
58
- }
59
- }
60
-
61
- /**
62
- * 开启 dsh 代理:备份 → settings.yaml 写 llm-deepseek.baseURL(幂等刷新)→
63
- * .credentials.yaml 写 refs.DEEPSEEK_API_KEY(masterKey 缺省时写占位值)。
64
- * 原文件不合法时直接报错(不覆盖坏文件);凭据文档仅支持 version:1 与旧版扁平两种形态。
65
- */
66
- export function dshEnable({ gatewayBase, masterKey }) {
67
- const target = targetBase(gatewayBase);
68
-
69
- // 1) settings.yaml:先解析校验,后备份写入
70
- mkdirSync(dshHome(), { recursive: true });
71
- const settingsPath = settingsFile();
72
- const origSettings = readTextIfExists(settingsPath);
73
- const doc = origSettings === null ? parseYamlDoc("", settingsPath) : parseYamlDoc(origSettings, settingsPath);
74
- const settingsData = doc.toJS();
75
- if (settingsData && typeof settingsData["llm-deepseek"] !== "undefined" && typeof settingsData["llm-deepseek"] !== "object") {
76
- throw new Error(`${settingsPath} 的 llm-deepseek 不是映射结构,拒绝修改`);
77
- }
78
- backupOnce(settingsPath);
79
- doc.setIn(["llm-deepseek", "baseURL"], target);
80
- writeValidatedYaml(settingsPath, doc);
81
-
82
- // 2) .credentials.yaml:官方托管凭据库(优先级低于进程环境变量,高于 .env 两层)
83
- const credPath = credentialsFile();
84
- const key = masterKey || PLACEHOLDER_KEY;
85
- const origCred = readTextIfExists(credPath);
86
- backupOnce(credPath);
87
- if (origCred === null) {
88
- const credDoc = parseYamlDoc("", credPath);
89
- credDoc.set("version", CREDENTIALS_VERSION);
90
- credDoc.setIn(["refs", KEY_REF], key);
91
- writeValidatedYaml(credPath, credDoc);
92
- return;
93
- }
94
- const credDoc = parseYamlDoc(origCred, credPath);
95
- const credData = credDoc.toJS();
96
- if (!isMap(credDoc.contents)) throw new Error(`${credPath} 根节点不是映射结构,拒绝修改`);
97
- if (typeof credData.version !== "undefined" && credData.version !== CREDENTIALS_VERSION) {
98
- throw new Error(`${credPath} 的 version 为 ${credData.version},本开关仅支持 version: ${CREDENTIALS_VERSION}`);
99
- }
100
- if (typeof credData.version === "undefined") {
101
- // 旧版扁平文档(dsh 启动时会自行迁移为 version:1):保持扁平形态写入
102
- const flat = Object.values(credData).every((v) => typeof v === "string" && v.length > 0);
103
- if (!flat) throw new Error(`${credPath} 既非 version:1 也非旧版扁平结构,请手工整理后重试`);
104
- credDoc.set(KEY_REF, key);
105
- } else {
106
- if (typeof credData.refs !== "undefined" && (typeof credData.refs !== "object" || credData.refs === null)) {
107
- throw new Error(`${credPath} 的 refs 不是映射结构,拒绝修改`);
108
- }
109
- credDoc.setIn(["refs", KEY_REF], key);
110
- }
111
- writeValidatedYaml(credPath, credDoc);
112
- }
113
-
114
- /**
115
- * 关闭 dsh 代理:优先从备份还原两个文件(各自独立备份);无备份时移除本开关写入的字段——
116
- * settings.yaml 仅在 baseURL 仍指向网关时移除(连带清理空 llm-deepseek 段);
117
- * 凭据库仅在 DEEPSEEK_API_KEY 取值为本开关写入值(masterKey 或占位值)时移除,避免误删用户自己的官方 key。
118
- * 返回是否实际发生了还原/移除。
119
- */
120
- export function dshDisable(gatewayBase, masterKey) {
121
- const settingsPath = settingsFile();
122
- const credPath = credentialsFile();
123
- let changed = false;
124
-
125
- if (restoreFromBackup(settingsPath)) {
126
- changed = true;
127
- } else {
128
- const text = readTextIfExists(settingsPath);
129
- if (text) {
130
- const doc = parseYamlDoc(text, settingsPath);
131
- const data = doc.toJS();
132
- if (sameUrl(data?.["llm-deepseek"]?.baseURL, targetBase(gatewayBase))) {
133
- doc.deleteIn(["llm-deepseek", "baseURL"]);
134
- const rest = doc.toJS()?.["llm-deepseek"];
135
- if (!rest || typeof rest !== "object" || Object.keys(rest).length === 0) doc.deleteIn(["llm-deepseek"]);
136
- writeValidatedYaml(settingsPath, doc);
137
- changed = true;
138
- }
139
- }
140
- }
141
-
142
- if (restoreFromBackup(credPath)) {
143
- changed = true;
144
- } else {
145
- const text = readTextIfExists(credPath);
146
- if (text) {
147
- const doc = parseYamlDoc(text, credPath);
148
- const data = doc.toJS();
149
- const ours = (v) => v === masterKey || v === PLACEHOLDER_KEY;
150
- if (data?.version === CREDENTIALS_VERSION && ours(data?.refs?.[KEY_REF])) {
151
- doc.deleteIn(["refs", KEY_REF]);
152
- if (Object.keys(doc.toJS()?.refs ?? {}).length === 0) doc.deleteIn(["refs"]);
153
- writeValidatedYaml(credPath, doc);
154
- changed = true;
155
- } else if (typeof data?.version === "undefined" && ours(data?.[KEY_REF])) {
156
- doc.deleteIn([KEY_REF]);
157
- writeValidatedYaml(credPath, doc);
158
- changed = true;
159
- }
160
- }
161
- }
162
- return changed;
163
- }
164
-
165
- /** 写盘前经库回读校验(结果必须可解析),失败保留原文件 */
166
- function writeValidatedYaml(file, doc) {
167
- const text = doc.toString();
168
- try {
169
- parseDocument(text);
170
- } catch (err) {
171
- throw new Error(`编辑后的 ${file} 不是合法 YAML,已保留原文件: ${err.message}`);
172
- }
173
- mkdirSync(dirname(file), { recursive: true });
174
- writeFileSync(file, text, "utf-8");
175
- }
1
+ // DeepSeek Harness(dsh)代理开关:~/.dsh/settings.yaml 的 llm-deepseek.baseURL 指向本机网关,
2
+ // 密钥写入官方托管凭据库 ~/.dsh/.credentials.yaml(refs.DEEPSEEK_API_KEY)。
3
+ // dsh 解析口径(0.1.x developer preview):llm-deepseek 适配器拼 {baseURL}/chat/completions,
4
+ // 故 baseURL 需带 /v1;密钥解析优先级 = 进程环境变量 > .credentials.yaml > .env(进程已设
5
+ // DEEPSEEK_API_KEY 时本开关写入的值不生效,需用户自行调整)。DSH_HOME env 可重定向 ~/.dsh。
6
+ // YAML 解析/校验用 yaml 库 Document API(保留用户注释),不手写解析器;写盘前经库回读校验。
7
+ import { readFileSync, writeFileSync, existsSync, mkdirSync } from "node:fs";
8
+ import { homedir } from "node:os";
9
+ import { join, dirname } from "node:path";
10
+ import { parseDocument, isMap } from "yaml";
11
+ import { backupOnce, restoreFromBackup } from "./backup.js";
12
+
13
+ /** dsh 密钥引用名(llm-deepseek 的 apiKeyEnv 默认值) */
14
+ const KEY_REF = "DEEPSEEK_API_KEY";
15
+ /** 凭据文档 schema 版本(dsh credentials-local 的 version: 1) */
16
+ const CREDENTIALS_VERSION = 1;
17
+ /** 网关未配 masterKey 时写入的占位密钥(值任意:网关未设 masterKey 时放行,但 dsh 本地要求非空) */
18
+ const PLACEHOLDER_KEY = "ellm-proxy";
19
+
20
+ const dshHome = () => process.env.DSH_HOME || join(homedir(), ".dsh");
21
+ const settingsFile = () => join(dshHome(), "settings.yaml");
22
+ const credentialsFile = () => join(dshHome(), ".credentials.yaml");
23
+
24
+ /** 去尾斜杠比较,容忍 http://x:1/ 与 http://x:1 视为同一地址 */
25
+ function sameUrl(a, b) {
26
+ return String(a || "").replace(/\/+$/, "") === String(b || "").replace(/\/+$/, "");
27
+ }
28
+
29
+ function readTextIfExists(file) {
30
+ if (!existsSync(file)) return null;
31
+ const text = readFileSync(file, "utf-8");
32
+ return text.trim() === "" ? null : text; // 空文件按不存在处理(dsh:absent = empty store)
33
+ }
34
+
35
+ /** 解析 YAML 文档(不合法抛错,保留原文件语义同 claude-code 的 JSON 处理) */
36
+ function parseYamlDoc(text, file) {
37
+ const doc = parseDocument(text);
38
+ if (doc.errors.length > 0) {
39
+ throw new Error(`${file} 不是合法 YAML,拒绝修改(请先手工修复): ${doc.errors[0].message}`);
40
+ }
41
+ return doc;
42
+ }
43
+
44
+ /** 开关目标端点:dsh 适配器在 baseURL 后拼 /chat/completions,故带 /v1 走网关正式路由 */
45
+ function targetBase(gatewayBase) {
46
+ return `${gatewayBase}/v1`;
47
+ }
48
+
49
+ /** dsh 开关状态:settings.yaml 的 llm-deepseek.baseURL 是否已指向网关(文件缺失/不合法按未开启) */
50
+ export function dshStatus(gatewayBase) {
51
+ const text = readTextIfExists(settingsFile());
52
+ if (!text) return false;
53
+ try {
54
+ const data = parseYamlDoc(text, settingsFile()).toJS();
55
+ return sameUrl(data?.["llm-deepseek"]?.baseURL, targetBase(gatewayBase));
56
+ } catch {
57
+ return false;
58
+ }
59
+ }
60
+
61
+ /**
62
+ * 开启 dsh 代理:备份 → settings.yaml 写 llm-deepseek.baseURL(幂等刷新)→
63
+ * .credentials.yaml 写 refs.DEEPSEEK_API_KEY(masterKey 缺省时写占位值)。
64
+ * 原文件不合法时直接报错(不覆盖坏文件);凭据文档仅支持 version:1 与旧版扁平两种形态。
65
+ */
66
+ export function dshEnable({ gatewayBase, masterKey }) {
67
+ const target = targetBase(gatewayBase);
68
+
69
+ // 1) settings.yaml:先解析校验,后备份写入
70
+ mkdirSync(dshHome(), { recursive: true });
71
+ const settingsPath = settingsFile();
72
+ const origSettings = readTextIfExists(settingsPath);
73
+ const doc = origSettings === null ? parseYamlDoc("", settingsPath) : parseYamlDoc(origSettings, settingsPath);
74
+ const settingsData = doc.toJS();
75
+ if (settingsData && typeof settingsData["llm-deepseek"] !== "undefined" && typeof settingsData["llm-deepseek"] !== "object") {
76
+ throw new Error(`${settingsPath} 的 llm-deepseek 不是映射结构,拒绝修改`);
77
+ }
78
+ backupOnce(settingsPath);
79
+ doc.setIn(["llm-deepseek", "baseURL"], target);
80
+ writeValidatedYaml(settingsPath, doc);
81
+
82
+ // 2) .credentials.yaml:官方托管凭据库(优先级低于进程环境变量,高于 .env 两层)
83
+ const credPath = credentialsFile();
84
+ const key = masterKey || PLACEHOLDER_KEY;
85
+ const origCred = readTextIfExists(credPath);
86
+ backupOnce(credPath);
87
+ if (origCred === null) {
88
+ const credDoc = parseYamlDoc("", credPath);
89
+ credDoc.set("version", CREDENTIALS_VERSION);
90
+ credDoc.setIn(["refs", KEY_REF], key);
91
+ writeValidatedYaml(credPath, credDoc);
92
+ return;
93
+ }
94
+ const credDoc = parseYamlDoc(origCred, credPath);
95
+ const credData = credDoc.toJS();
96
+ if (!isMap(credDoc.contents)) throw new Error(`${credPath} 根节点不是映射结构,拒绝修改`);
97
+ if (typeof credData.version !== "undefined" && credData.version !== CREDENTIALS_VERSION) {
98
+ throw new Error(`${credPath} 的 version 为 ${credData.version},本开关仅支持 version: ${CREDENTIALS_VERSION}`);
99
+ }
100
+ if (typeof credData.version === "undefined") {
101
+ // 旧版扁平文档(dsh 启动时会自行迁移为 version:1):保持扁平形态写入
102
+ const flat = Object.values(credData).every((v) => typeof v === "string" && v.length > 0);
103
+ if (!flat) throw new Error(`${credPath} 既非 version:1 也非旧版扁平结构,请手工整理后重试`);
104
+ credDoc.set(KEY_REF, key);
105
+ } else {
106
+ if (typeof credData.refs !== "undefined" && (typeof credData.refs !== "object" || credData.refs === null)) {
107
+ throw new Error(`${credPath} 的 refs 不是映射结构,拒绝修改`);
108
+ }
109
+ credDoc.setIn(["refs", KEY_REF], key);
110
+ }
111
+ writeValidatedYaml(credPath, credDoc);
112
+ }
113
+
114
+ /**
115
+ * 关闭 dsh 代理:优先从备份还原两个文件(各自独立备份);无备份时移除本开关写入的字段——
116
+ * settings.yaml 仅在 baseURL 仍指向网关时移除(连带清理空 llm-deepseek 段);
117
+ * 凭据库仅在 DEEPSEEK_API_KEY 取值为本开关写入值(masterKey 或占位值)时移除,避免误删用户自己的官方 key。
118
+ * 返回是否实际发生了还原/移除。
119
+ */
120
+ export function dshDisable(gatewayBase, masterKey) {
121
+ const settingsPath = settingsFile();
122
+ const credPath = credentialsFile();
123
+ let changed = false;
124
+
125
+ if (restoreFromBackup(settingsPath)) {
126
+ changed = true;
127
+ } else {
128
+ const text = readTextIfExists(settingsPath);
129
+ if (text) {
130
+ const doc = parseYamlDoc(text, settingsPath);
131
+ const data = doc.toJS();
132
+ if (sameUrl(data?.["llm-deepseek"]?.baseURL, targetBase(gatewayBase))) {
133
+ doc.deleteIn(["llm-deepseek", "baseURL"]);
134
+ const rest = doc.toJS()?.["llm-deepseek"];
135
+ if (!rest || typeof rest !== "object" || Object.keys(rest).length === 0) doc.deleteIn(["llm-deepseek"]);
136
+ writeValidatedYaml(settingsPath, doc);
137
+ changed = true;
138
+ }
139
+ }
140
+ }
141
+
142
+ if (restoreFromBackup(credPath)) {
143
+ changed = true;
144
+ } else {
145
+ const text = readTextIfExists(credPath);
146
+ if (text) {
147
+ const doc = parseYamlDoc(text, credPath);
148
+ const data = doc.toJS();
149
+ const ours = (v) => v === masterKey || v === PLACEHOLDER_KEY;
150
+ if (data?.version === CREDENTIALS_VERSION && ours(data?.refs?.[KEY_REF])) {
151
+ doc.deleteIn(["refs", KEY_REF]);
152
+ if (Object.keys(doc.toJS()?.refs ?? {}).length === 0) doc.deleteIn(["refs"]);
153
+ writeValidatedYaml(credPath, doc);
154
+ changed = true;
155
+ } else if (typeof data?.version === "undefined" && ours(data?.[KEY_REF])) {
156
+ doc.deleteIn([KEY_REF]);
157
+ writeValidatedYaml(credPath, doc);
158
+ changed = true;
159
+ }
160
+ }
161
+ }
162
+ return changed;
163
+ }
164
+
165
+ /** 写盘前经库回读校验(结果必须可解析),失败保留原文件 */
166
+ function writeValidatedYaml(file, doc) {
167
+ const text = doc.toString();
168
+ try {
169
+ parseDocument(text);
170
+ } catch (err) {
171
+ throw new Error(`编辑后的 ${file} 不是合法 YAML,已保留原文件: ${err.message}`);
172
+ }
173
+ mkdirSync(dirname(file), { recursive: true });
174
+ writeFileSync(file, text, "utf-8");
175
+ }