ellm-proxy 0.0.1

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 (47) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +60 -0
  3. package/bin/commands/autostart.js +213 -0
  4. package/bin/commands/config.js +8 -0
  5. package/bin/commands/pm2.js +28 -0
  6. package/bin/commands/service.js +107 -0
  7. package/bin/ellm-proxy.js +61 -0
  8. package/dist/package.json +1 -0
  9. package/dist/server.js +12176 -0
  10. package/dist/web-ui/assets/DashboardView-CQdlgirS.js +1 -0
  11. package/dist/web-ui/assets/DashboardView-qrQrkgLA.css +1 -0
  12. package/dist/web-ui/assets/LayoutView-BaY4Pz9W.js +1 -0
  13. package/dist/web-ui/assets/LoginView-CPvz1Ylx.js +1 -0
  14. package/dist/web-ui/assets/LoginView-Dg0mB6ok.css +1 -0
  15. package/dist/web-ui/assets/LogsView-B-bIj36j.js +9 -0
  16. package/dist/web-ui/assets/LogsView-Wl3_adHm.css +1 -0
  17. package/dist/web-ui/assets/ProcessView-0x_ShSwV.js +1 -0
  18. package/dist/web-ui/assets/ProcessView-DzMqBJmd.css +1 -0
  19. package/dist/web-ui/assets/ProvidersView-BxAxDRFH.css +1 -0
  20. package/dist/web-ui/assets/ProvidersView-GQENcvJS.js +31 -0
  21. package/dist/web-ui/assets/SettingsView-BDkymsrS.css +1 -0
  22. package/dist/web-ui/assets/SettingsView-BrAiaskZ.js +1 -0
  23. package/dist/web-ui/assets/config-DNn5cjYY.js +1 -0
  24. package/dist/web-ui/assets/index-BecfPmag.css +1 -0
  25. package/dist/web-ui/assets/index-DFlUx18F.js +96 -0
  26. package/dist/web-ui/assets/providers-CGEmOxne.js +1 -0
  27. package/dist/web-ui/assets/useApi-BRPinb_2.js +1 -0
  28. package/dist/web-ui/assets/useManagerApi-BDWJz5o6.js +1 -0
  29. package/dist/web-ui/assets/useToast-D7xlwSg6.js +1 -0
  30. package/dist/web-ui/config.js +10 -0
  31. package/dist/web-ui/index.html +15 -0
  32. package/docs/DESIGN.md +99 -0
  33. package/ellm.config.example.json +33 -0
  34. package/manager/api/harness/backup.js +20 -0
  35. package/manager/api/harness/claude-code.js +54 -0
  36. package/manager/api/harness/codex.js +123 -0
  37. package/manager/api/harness/dsh.js +175 -0
  38. package/manager/api/harness/index.js +118 -0
  39. package/manager/api/service.js +157 -0
  40. package/manager/index.js +170 -0
  41. package/package.json +40 -0
  42. package/utils/interpreter.js +14 -0
  43. package/utils/paths.js +44 -0
  44. package/utils/pm2.js +130 -0
  45. package/utils/port.js +35 -0
  46. package/utils/service.js +189 -0
  47. package/utils/ui.js +59 -0
@@ -0,0 +1 @@
1
+ import{U as w,l as u,f as i}from"./index-DFlUx18F.js";import{u as C}from"./config-DNn5cjYY.js";const J=w("providers",()=>{const s=C(),c=u(!1),n=i(()=>s.providers),v=i(()=>s.loading),f=i(()=>n.value.length),m=i(()=>n.value.reduce((e,o)=>{var r;return e+(((r=o.models)==null?void 0:r.length)??0)},0)),t=u([]);function p(){try{const e=localStorage.getItem("provider_order");e&&(t.value=JSON.parse(e))}catch{}}function d(){try{localStorage.setItem("provider_order",JSON.stringify(t.value))}catch{}}const g=i(()=>{const e=t.value.map(r=>n.value.find(a=>a.name===r)).filter(r=>!!r);for(const r of n.value)e.find(a=>a.name===r.name)||e.push(r);const o=e.map(r=>r.name);return(o.length!==t.value.length||o.some((r,a)=>t.value[a]!==r))&&(t.value=o,d()),e});function S(e){t.value=e.map(o=>o.name),d()}function h(e){t.value=[e,...t.value.filter(o=>o!==e)],d()}async function y(){p(),await s.load()}async function l(e){return s.config.providers=e,await s.save()}async function P(e){const o=n.value.filter(r=>r.name!==e.name);return await l(o)}async function O(e){const o=n.value.map(r=>({...r,models:(r.models??[]).filter(a=>a.modelName!==e)}));return await l(o)}return{suppliers:n,loading:v,restarting:c,orderedProviders:g,providerCount:f,totalModelCount:m,loadProviders:y,saveProviders:l,deleteProvider:P,deleteModel:O,reorderProviders:S,moveProviderToTop:h}});export{J as u};
@@ -0,0 +1 @@
1
+ import{a2 as f}from"./index-DFlUx18F.js";var h;const g=((h=window.__ELLM_CONFIG__)==null?void 0:h.API_BASE)??"",p=3e4;let n=null;function _(){return n===null&&(n=localStorage.getItem("webui_token")),n}function m(){n=null,localStorage.removeItem("webui_token"),f.push("/login")}async function e(t,o,s){const l={"Content-Type":"application/json"},c=_();c&&(l.Authorization=`Bearer ${c}`);const u=new AbortController,E=setTimeout(()=>u.abort(),p);try{const r=await fetch(`${g}${o}`,{method:t,headers:l,body:s!==void 0?JSON.stringify(s):void 0,signal:u.signal});if(r.status===401){const T=await r.text();let i={};try{i=T?JSON.parse(T):{}}catch{}throw o==="/api/auth/login"?new Error(i.error||"用户名或密码错误"):(m(),new Error(i.error||"未登录"))}const w=await r.text();let a;try{a=w?JSON.parse(w):{}}catch{throw new Error(`响应格式错误 (HTTP ${r.status})`)}if(!r.ok)throw new Error(a.error||`HTTP ${r.status}`);return a}catch(r){throw r.name==="AbortError"?new Error("请求超时"):r}finally{clearTimeout(E)}}const S={get:t=>e("GET",t),post:(t,o)=>e("POST",t,o),put:(t,o)=>e("PUT",t,o),del:t=>e("DELETE",t)};export{S as A};
@@ -0,0 +1 @@
1
+ var l;const c=((l=window.__ELLM_CONFIG__)==null?void 0:l.API_BASE)??"";function u(){try{const t=c?new URL(c):new URL(location.href);return t.port?`${t.protocol}//${t.hostname}:${Number(t.port)+1}`:null}catch{return null}}var h;const i=((h=window.__ELLM_CONFIG__)==null?void 0:h.MANAGER_BASE)??u(),T=3e4;async function r(t,E,s){if(i===null)throw new Error("无法确定管理服务地址:config.js 未配置 MANAGER_BASE,且页面经 80/443 端口访问(无法按端口+1 推导)");const n=new AbortController,w=setTimeout(()=>n.abort(),T);try{const e=await fetch(`${i}${E}`,{method:t,headers:{"Content-Type":"application/json"},body:s!==void 0?JSON.stringify(s):void 0,signal:n.signal}),a=await e.text();let o;try{o=a?JSON.parse(a):{}}catch{throw new Error(`管理服务响应格式错误 (HTTP ${e.status})`)}if(!e.ok||o.ok===!1)throw new Error(o.error||`HTTP ${e.status}`);return o}catch(e){throw e.name==="AbortError"?new Error("请求超时"):e instanceof TypeError?new Error("管理服务不可达(请确认 ellm-proxy 已启动,且本页面通过 localhost/127.0.0.1 访问)"):e}finally{clearTimeout(w)}}const p={status:()=>r("GET","/status"),processes:()=>r("GET","/processes").then(t=>t.processes),start:()=>r("POST","/start"),stop:()=>r("POST","/stop"),restart:()=>r("POST","/restart"),harness:()=>r("GET","/harness"),setClaudeCode:t=>r("POST","/harness/claude-code",{enabled:t}),setCodex:t=>r("POST","/harness/codex",{enabled:t}),setDsh:t=>r("POST","/harness/dsh",{enabled:t})};export{p as m};
@@ -0,0 +1 @@
1
+ import{a1 as a}from"./index-DFlUx18F.js";function u(){function s(t,o="success"){a({message:t,type:o,duration:3e3})}return{toast:s}}export{u};
@@ -0,0 +1,10 @@
1
+ // web-ui 运行时配置(部署后可直接改这个文件,无需重新构建)
2
+ // 标准形态:manager 管理服务托管本目录(http://127.0.0.1:<主端口+1>/),两项留空即全同源,无需修改。
3
+ // API_BASE:管理台 API(/api/*)的基地址。留空 = 同源(manager 已反代 /api/* 到主服务);
4
+ // 仅当把 dist 部署到其他静态服务器时填写,如 "http://192.168.1.10:29770"(backend 全局 CORS 已开)。
5
+ // MANAGER_BASE:进程/代理开关接口(/status /restart /harness…)的基地址。
6
+ // 留空 = 同源(manager 托管形态);独立部署且需进程管理页时填 manager 地址,如 "http://127.0.0.1:29771"。
7
+ window.__ELLM_CONFIG__ = {
8
+ API_BASE: "",
9
+ MANAGER_BASE: "",
10
+ };
@@ -0,0 +1,15 @@
1
+ <!DOCTYPE html>
2
+ <html lang="zh-CN">
3
+ <head>
4
+ <meta charset="UTF-8">
5
+ <link rel="icon" type="image/svg+xml" href="/vite.svg">
6
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
7
+ <title>ELLM - 网关管理台</title>
8
+ <script src="/config.js"></script>
9
+ <script type="module" crossorigin src="/assets/index-DFlUx18F.js"></script>
10
+ <link rel="stylesheet" crossorigin href="/assets/index-BecfPmag.css">
11
+ </head>
12
+ <body>
13
+ <div id="app"></div>
14
+ </body>
15
+ </html>
package/docs/DESIGN.md ADDED
@@ -0,0 +1,99 @@
1
+ # ellm-proxy 发布包需求与设计文档
2
+
3
+ > 本文档整理 2026-09-01 「将 ellm 项目改造为 npm 发布包」需求的完整决策与设计,供后续维护者理解包结构与行为约束。使用说明见 [README.md](../README.md)。
4
+
5
+ ## 1. 背景与目标
6
+
7
+ `ellm` monorepo(`gateway` 协议转换层 / `backend` 管理台服务 / `web-ui` Vue 前端)原为纯本地工程,目标是把 backend 单进程应用(内嵌网关 + 管理台 API)改造为可发布的 npm 包 **`ellm-proxy`**(2026-09-03 起 web-ui 由 manager 管理服务托管,backend 不再托管):
8
+
9
+ ```bash
10
+ npm i -g ellm-proxy
11
+ ellm-proxy start # 后台启动(隐藏窗口,首次自动创建配置)
12
+ ellm-proxy stop # 停止后台服务
13
+ ```
14
+
15
+ ## 2. 需求决策记录(按定案时间线)
16
+
17
+ | # | 决策 | 内容 |
18
+ | --- | --- | --- |
19
+ | 1 | 发布形态 | 完整单进程应用(网关 + 管理台 API),非 SDK。web-ui 静态托管 2026-09-03 起由 manager 管理服务承担(见决策 12) |
20
+ | 2 | 包名 | 坚持 `ellm-proxy`(与内部子包 `gateway` 相似性已知并接受) |
21
+ | 3 | 发布载体 | 新增 workspace 子包 `ellm-proxy/`,backend/web-ui 保持原职责;发布时先构建再聚合 |
22
+ | 4 | backend 纯净度 | backend 源码保持单模式零分支;发布差异全部由环境变量承载(经历三轮演进,见 §4) |
23
+ | 5 | 运行时布局 | 配置/数据/日志/PID 默认放用户目录 `~/.ellm-proxy/`,**全部可通过环境变量注入覆盖**,无则走默认 |
24
+ | 6 | CLI 入口 | `ellm-proxy` 命令为独立 JS CLI(`bin/ellm-proxy.js`):`-h` 内置帮助、`start`/`stop`/`restart`/`config` 子命令 |
25
+ | 7 | 首次配置自愈 | 首次执行时检查配置文件,不存在则从包内示例自动创建,并提示两种修改方式(编辑文件 / web-ui 在线修改) |
26
+ | 8 | 服务启停形态 | 实际服务由包内自带的局部 **pm2**(JS API)守护(进程名 `ellm-proxy`):崩溃自动重启、日志落盘、dump/resurrect;CLI 直接调 pm2,不再有独立启停脚本 |
27
+ | 9 | 配置修改渠道 | 自动创建后提示直接编辑配置文件,或启动服务后经管理台(独立部署的 web-ui)在线修改(实时热更新) |
28
+ | 10 | 默认端口 | 3000 太常用易冲突 → 随机定为 **29770**(避开 49152+ 系统动态端口区) |
29
+ | 11 | 不内嵌 node.exe | 服务要求的 Node ≥ 22.13.0 由 CLI 解析为 pm2 `interpreter`:CLI 自身 node 满足则用 `process.execPath`,否则经 **get-node**(随包依赖)解析/下载(缓存命中零下载);pm2 daemon 的 node 版本无关紧要 |
30
+ | 12 | web-ui 移交 manager 托管(2026-09-03) | backend 移除 `/web-ui` 静态托管(删 `webui-static.ts`);web-ui 改由 manager 管理服务托管:根路径静态资源 + 反代 `/api/*` 到主服务(端口每请求解析,主服务顺延漂移无感知),页面全接口同源零配置(`config.js` 的 `API_BASE`/`MANAGER_BASE` 留空);发布包恢复捆绑 `dist/web-ui/` 供 manager 定位(`ELLM_WEBUI_DIR` 可选覆盖,CLI 不再注入);manager 仍仅监听 127.0.0.1 |
31
+
32
+ ## 3. 架构设计
33
+
34
+ ### 3.1 包结构与发布流程
35
+
36
+ ```
37
+ ellm-proxy/
38
+ ├── bin/ellm-proxy.js # CLI 入口(npm bin 指向;纯 JS 零依赖)
39
+ ├── dist/server.js # 发布版服务本体(esbuild 从 backend 源码直接构建 + bin shebang banner)
40
+ ├── dist/web-ui/ # web-ui vite 构建产物(manager 管理服务托管于 127.0.0.1:<主端口+1>/)
41
+ ├── manager/ # 管理服务(独立常驻):进程控制 + harness 开关 + web-ui 托管 + /api 反代
42
+ ├── scripts/build-server.mjs # 发布构建(纯 esbuild,无任何源码替换)
43
+ ├── scripts/build-package.mjs # 聚合 + 门禁(prepack:构建 server → 拷 web-ui → 复制 example → shebang 校验)
44
+ └── release/ → 已删除 # 曾存在的"包模式路径模块"已被全 env 化方案取代
45
+ ```
46
+
47
+ 发布命令(仓库根):`pnpm pack:proxy` = `build:web` → `npm pack ./ellm-proxy`(prepack 自动构建聚合)。
48
+
49
+ 发布 manifest 要点:`engines >=22.13.0`(node:sqlite 无 flag 最低版);`bin=bin/ellm-proxy.js`;`files` 白名单(bin/、dist/、example、README、LICENSE、docs);不加 `private`;`dependencies` 为 `cac@^6`(CLI 框架)、`pm2@^7`(进程守护,局部随包分发)与 `get-node@^15`(解析满足 engines 的 node 作为 pm2 interpreter);bundle 自包含;发布时用 `npm publish`(不改写 manifest)。
50
+
51
+ ### 3.2 路径全 env 化(核心设计)
52
+
53
+ 所有路径「env 优先、缺省公式兜底」,相对路径按当前工作目录展开,绝对路径原样:
54
+
55
+ | 配置 | 环境变量 | 缺省公式(仓库模式自动正确) | 包模式来源 |
56
+ | --- | --- | --- | --- |
57
+ | 应用目录(配置/数据基准) | `ELLM_APP_DIR` | `backend/`(hereDir 推导) | start 脚本注入 = 工作目录(默认 `~/.ellm-proxy`) |
58
+ | 配置文件 | `ELLM_CONFIG` | `<APP_DIR>/ellm.config.json` | CLI `--config` 或用户 env |
59
+ | 数据目录 | `ELLM_DATA_DIR` | `<APP_DIR>/data` | CLI `--data-dir` 或用户 env |
60
+ | web-ui 静态资源 | `ELLM_WEBUI_DIR`(可选覆盖) | —(manager 按自身位置推导:包模式 `dist/web-ui` → 仓库模式 `web-ui/dist`) | manager 启动时自解析(CLI 不再注入) |
61
+
62
+ 服务端不加载任何 `.env` / env 文件;环境变量一律由用户会话导出或 CLI 注入。
63
+
64
+ 设计约束与边界:
65
+
66
+ - **backend 源码零模式分支**:`paths.ts` 只有「env ?? 公式」两种取值,不存在模式判断;仓库开发模式不设任何 env 即工作。
67
+ - **包模式必须经 CLI 启动**:绕过它裸跑 `dist/server.js` 时 env 未注入,会因找不到配置退出(报错信息指引)。官方入口始终是 `ellm-proxy start/stop`。
68
+ - **CLI 在转调前把路径参数统一转为绝对路径**:CLI 的 cwd 与 node 进程的工作目录不同,透传相对字符串会在另一基准下解析错位。
69
+ - **CLI 负责首次配置自愈**(server 端只读不建):`ensureConfig()` 查找链与 server 端一致,创建后提示编辑或 web-ui 修改。
70
+
71
+ ### 3.3 后台启停设计(pm2,跨平台)
72
+
73
+ - **start**:pm2 JS API `connect → list(同名已存在则报错)→ start(数组形式一次提交主服务+管理服务)→ 并行轮询探活(waitOnline 每 300ms 查 status 至 online;errored/stopped 提前失败、15s 超时兜底,双启路径 quiet 静默轮询、结果合并为一行 ✓/✗)→ dump`。启动全程分步进度输出,等待期间不留静默空窗;管理服务已在线(部分恢复)则只补启主服务。主服务探活失败时管理服务已在线(其职责正是远程拉起主服务),失败不回收进程,错误文案给出重试路径。app 配置内联构造:`name=ellm-proxy`、`script=dist/server.js`(`--server-js` 可覆盖)、`interpreter`(CLI node 满足 engines 用 execPath,否则 get-node 解析/下载)、`cwd=appDir`、`out_file/error_file=<appDir>/logs/`、`max_restarts=10 + restart_delay=3s`(防端口占用无限重启)、`env={...process.env}`(含 ELLM_APP_DIR/CONFIG/DATA_DIR/ELLM_PORT/ELLM_MANAGER_PORT 注入,端口由 doStart 统一预算:主服务 = --port > ELLM_PORT env > 配置 port,管理服务 = 主端口+1;预算后试绑探测,被占自动顺延空闲端口——主服务 0.0.0.0+127.0.0.1 双维度、管理服务 127.0.0.1 单维度(Windows wildcard/specific 共存语义,见 utils/port.js),顺延仅本次运行生效不改配置文件;管理服务已在线时保持现有端口不重新预算)。
74
+ - **stop/restart**:stop = `pm2 delete`(不存在则幂等提示)+ dump 更新;restart = stop + start(保证最新参数/env 生效)。进程管理全部由 pm2 承担,无自研 PID/树杀逻辑。
75
+ - **开机自启(跨平台,登录时触发、均免管理员)**:`autostart on` 按平台注册自启项,内容统一为 `<nodePath> <包内 pm2 bin> resurrect`(恢复最后一次 dump;从未 start 过则静默无副作用)——Windows 写启动文件夹 `.cmd`(shell:startup;schtasks onlogon 需管理员实测被拒)、macOS 写 LaunchAgent plist + `launchctl load`、Linux 写 systemd user unit + `systemctl --user enable`(无 systemd 的 WSL/容器环境报错并提示手动处理);`autostart off` 反向移除。
76
+ - **日志/状态基准**:`--working-dir`(默认 `~/.ellm-proxy`,即 `ELLM_APP_DIR` 同源);运行状态用 `pm2 ls`。
77
+
78
+ ### 3.4 跨包不变式(维护须知)
79
+
80
+ 1. **`gateway/src/index.ts` 必须保持零顶层副作用**:backend 经 esbuild 全量内联 gateway 源码,任何 import 副作用都会执行。曾因 index.ts 顶层读 `ELLM_CONFIG` 装配 default app 与 backend 同名 env 撞车(实测踩坑)。独立网关装配已抽至 `src/standalone-app.ts`(`buildStandaloneApp()`)。
81
+ 2. **包内布局约束**:`dist/web-ui/` 与 `dist/server.js` 同级(manager 缺省推导依赖此布局;`ELLM_WEBUI_DIR` 可覆盖)。
82
+ 3. **`backend/ellm.config.json` 仍是配置唯一数据源**(原样读写不变式在发布包同样成立)。
83
+
84
+ ## 4. 方案演进史(三轮,记录否决原因避免重走)
85
+
86
+ | 轮 | 方案 | 否决原因 |
87
+ | --- | --- | --- |
88
+ | 一 | backend 源码做双模式分支(`PACKAGE_MODE` 判据 + `BACKEND_DIR`/`REPO_ROOT` 为 null + server.ts CLI) | backend 侵入大,用户要求还原 |
89
+ | 二 | 保留 CLI/查找链在 backend(72 行无分支增量),发布差异收敛到一个模块(`release/runtime-dir.ts`)+ esbuild onLoad 插件构建期替换 | 用户希望路径全部 env 可传入、删除替换层 |
90
+ | 三(定稿) | 路径全 env 化(`ELLM_APP_DIR`/`ELLM_WEBUI_DIR` 注入由 ps1 承担),删除 release 模块与替换插件,backend 回归零分支 | — |
91
+
92
+ 期间曾实现 server.ts CLI(`--config/--port/--help/--version`),随「CLI 独立为 bin 脚本 + 配置路径走 env」定案被还原删除。
93
+
94
+ ## 5. 验证基线(发布前回归清单)
95
+
96
+ - `pnpm -r typecheck` + `pnpm -r test` 全绿(typecheck ×2、约 396 测试)
97
+ - CLI:`-h`、`config`(首次创建)、`start`(参数透传链 + env 注入 + 401/200 鉴权)、`stop`(树杀 + 端口释放)、`restart`(运行中/未运行两态)
98
+ - 仓库产物模式(零 env)与包模式(CLI 注入)双模式启动均正常
99
+ - 包目录零读写(`ls` 包目录无新增文件)、`npm pack --dry-run` 核对 tarball 无 `src/`/`test/`/真实配置
@@ -0,0 +1,33 @@
1
+ {
2
+ "port": 29770,
3
+ "compression": "passthrough",
4
+ "logContent": false,
5
+ "masterKey": "",
6
+ "providers": [
7
+ {
8
+ "name": "my-openai",
9
+ "alias": "my",
10
+ "customLlmProvider": "openai",
11
+ "apiKey": "os.environ/MY_OPENAI_API_KEY",
12
+ "apiBase": "https://api.openai.com/v1",
13
+ "models": [
14
+ { "modelName": "my/gpt-4o-mini", "model": "gpt-4o-mini" }
15
+ ]
16
+ },
17
+ {
18
+ "name": "my-anthropic",
19
+ "customLlmProvider": "anthropic",
20
+ "apiKey": "os.environ/MY_ANTHROPIC_API_KEY",
21
+ "models": [
22
+ { "modelName": "my/claude-sonnet-4-5", "model": "claude-sonnet-4-5" },
23
+ {
24
+ "modelName": "my/mixed-model",
25
+ "model": "some-model",
26
+ "customLlmProvider": "openai",
27
+ "apiBase": "https://example.com/v1",
28
+ "apiKey": "os.environ/OTHER_KEY"
29
+ }
30
+ ]
31
+ }
32
+ ]
33
+ }
@@ -0,0 +1,20 @@
1
+ // harness 配置文件备份/还原(claude-code 与 codex 共用):
2
+ // 开启前备份原文件为 <file>.ellm-bak(已存在则保留最早原始配置不覆盖);关闭 = 从备份还原并删除备份。
3
+ import { existsSync, copyFileSync, rmSync } from "node:fs";
4
+
5
+ /** 备份原文件(<file>.ellm-bak;备份已存在则保留最早原始配置不覆盖)。返回是否新建了备份 */
6
+ export function backupOnce(file) {
7
+ const bak = `${file}.ellm-bak`;
8
+ if (existsSync(bak)) return false;
9
+ if (existsSync(file)) copyFileSync(file, bak);
10
+ return true;
11
+ }
12
+
13
+ /** 从备份还原并删除备份;无备份返回 false */
14
+ export function restoreFromBackup(file) {
15
+ const bak = `${file}.ellm-bak`;
16
+ if (!existsSync(bak)) return false;
17
+ copyFileSync(bak, file);
18
+ rmSync(bak, { force: true });
19
+ return true;
20
+ }
@@ -0,0 +1,54 @@
1
+ // Claude Code 代理开关:~/.claude/settings.json 的 env.ANTHROPIC_BASE_URL / ANTHROPIC_AUTH_TOKEN
2
+ // 指向本机 ellm-proxy 网关。JSON 字段级修改,其余字段原样保留。
3
+ // 开关状态以实际文件内容判定(env.ANTHROPIC_BASE_URL 是否已指向网关);不落额外状态文件。
4
+ import { readFileSync, writeFileSync, existsSync, mkdirSync } from "node:fs";
5
+ import { homedir } from "node:os";
6
+ import { join, dirname } from "node:path";
7
+ import { backupOnce, restoreFromBackup } from "./backup.js";
8
+
9
+ const CLAUDE_SETTINGS = join(homedir(), ".claude", "settings.json");
10
+
11
+ /** 去尾斜杠比较,容忍 http://x:1/ 与 http://x:1 视为同一地址 */
12
+ function sameUrl(a, b) {
13
+ return String(a || "").replace(/\/+$/, "") === String(b || "").replace(/\/+$/, "");
14
+ }
15
+
16
+ function readClaudeSettings() {
17
+ if (!existsSync(CLAUDE_SETTINGS)) return null;
18
+ try {
19
+ return JSON.parse(readFileSync(CLAUDE_SETTINGS, "utf-8"));
20
+ } catch (err) {
21
+ throw new Error(`${CLAUDE_SETTINGS} 不是合法 JSON,拒绝修改(请先手工修复): ${err.message}`);
22
+ }
23
+ }
24
+
25
+ /** Claude Code 开关状态:env.ANTHROPIC_BASE_URL 是否已指向网关 */
26
+ export function claudeCodeStatus(gatewayBase) {
27
+ const settings = readClaudeSettings();
28
+ if (!settings) return false;
29
+ return sameUrl(settings?.env?.ANTHROPIC_BASE_URL, gatewayBase);
30
+ }
31
+
32
+ /** 开启 Claude Code 代理:备份 → 字段级写入 env.ANTHROPIC_BASE_URL / ANTHROPIC_AUTH_TOKEN(其余字段原样保留) */
33
+ export function claudeCodeEnable({ gatewayBase, token }) {
34
+ mkdirSync(dirname(CLAUDE_SETTINGS), { recursive: true });
35
+ const settings = readClaudeSettings() ?? {};
36
+ backupOnce(CLAUDE_SETTINGS);
37
+ settings.env = { ...(settings.env ?? {}) };
38
+ settings.env.ANTHROPIC_BASE_URL = gatewayBase;
39
+ settings.env.ANTHROPIC_AUTH_TOKEN = token;
40
+ writeFileSync(CLAUDE_SETTINGS, JSON.stringify(settings, null, 2) + "\n", "utf-8");
41
+ }
42
+
43
+ /** 关闭 Claude Code 代理:从备份还原(无备份时仅移除代理相关字段) */
44
+ export function claudeCodeDisable(gatewayBase) {
45
+ if (restoreFromBackup(CLAUDE_SETTINGS)) return true;
46
+ const settings = readClaudeSettings();
47
+ if (!settings) return false;
48
+ if (!sameUrl(settings?.env?.ANTHROPIC_BASE_URL, gatewayBase)) return false;
49
+ delete settings.env.ANTHROPIC_BASE_URL;
50
+ delete settings.env.ANTHROPIC_AUTH_TOKEN;
51
+ if (settings.env && Object.keys(settings.env).length === 0) delete settings.env;
52
+ writeFileSync(CLAUDE_SETTINGS, JSON.stringify(settings, null, 2) + "\n", "utf-8");
53
+ return true;
54
+ }
@@ -0,0 +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
+ }
@@ -0,0 +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
+ }