mslxdff 0.1.45 → 0.1.55
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/README.md +162 -162
- package/bin/mslxdff.js +202 -21
- package/docs/adr/0001-reasoning-content-injection.md +13 -13
- package/docs/adr/0002-models-free-filter.md +11 -11
- package/docs/adr/0003-zero-state-no-auth.md +9 -9
- package/docs/adr/0004-bearer-token.md +17 -17
- package/docs/agents/domain.md +50 -50
- package/docs/agents/issue-tracker.md +29 -29
- package/docs/agents/triage-labels.md +14 -14
- package/package.json +1 -1
- package/src/auto.js +20 -2
- package/src/chooser.js +12 -6
- package/src/daemon.js +84 -84
- package/src/logs.js +41 -2
- package/src/models.js +127 -127
- package/src/reasoning.js +32 -32
- package/src/routes/chat/broadband-handler.js +88 -0
- package/src/routes/chat/exhausted-handler.js +41 -0
- package/src/routes/chat/hedge-handler.js +142 -0
- package/src/routes/chat/index.js +178 -0
- package/src/routes/chat/local-handler.js +88 -0
- package/src/routes/chat/peer-handler.js +58 -0
- package/src/routes/chat.js +3 -342
- package/src/routes/hedge.js +251 -0
- package/src/routes/peers.js +49 -15
- package/src/server.js +57 -57
- package/src/state.js +285 -152
- package/src/sync-workbuddy.js +104 -0
- package/src/upstream.js +311 -212
package/README.md
CHANGED
|
@@ -1,162 +1,162 @@
|
|
|
1
|
-
# mslxdff
|
|
2
|
-
|
|
3
|
-
测试项目,请勿使用。
|
|
4
|
-
|
|
5
|
-
Zero runtime dependencies: Node ≥ 20, built-in `node:http`, `node:crypto`, `node:test`.
|
|
6
|
-
|
|
7
|
-
## What it does
|
|
8
|
-
|
|
9
|
-
- `POST /v1/chat/completions` — forwards your OpenAI-format request to the upstream (with reasoning-content injection for thinking-mode DeepSeek/Kimi models), streams SSE back chunk-by-chunk, or passes through JSON for non-streaming calls.
|
|
10
|
-
- `GET /v1/models` — the ~7 free models (`*-free` plus `big-pickle`), filtered from the full upstream list, cached and refreshed in the background every 2 hours.
|
|
11
|
-
- `GET /health` — public liveness check.
|
|
12
|
-
|
|
13
|
-
### `auto` model
|
|
14
|
-
|
|
15
|
-
Omitting `model` (or passing `"auto"`) picks a free model automatically, DeepSeek first. On an upstream error the next candidate is tried in the same request; each model's last-error timestamp is recorded (persisted in the state file) so the most reliably-available model is preferred next time.
|
|
16
|
-
|
|
17
|
-
### Fallback for a specific model
|
|
18
|
-
|
|
19
|
-
Pointing at a specific model (e.g. `deepseek-v4-flash-free`) still gets failover: if that model errors, the request automatically falls through to the next free model so your task isn't interrupted, and the error is recorded. The model enters a cooldown window (default 60s, `MSLXDFF_MODEL_COOLDOWN_MS`); during cooldown the backup models are used directly, and afterwards your model is tried again — once it succeeds it keeps being used until it errors again.
|
|
20
|
-
|
|
21
|
-
## Install & run
|
|
22
|
-
|
|
23
|
-
```
|
|
24
|
-
npm install # no deps actually fetched; just links the bin
|
|
25
|
-
mslxdff # or: node bin/mslxdff.js
|
|
26
|
-
```
|
|
27
|
-
|
|
28
|
-
First run generates a bearer token, writes it to the state file, and prints it:
|
|
29
|
-
|
|
30
|
-
```
|
|
31
|
-
mslxdff listening on http://localhost:8989
|
|
32
|
-
auth token: 9b5de021e914...
|
|
33
|
-
endpoint: http://localhost:8989/v1
|
|
34
|
-
```
|
|
35
|
-
|
|
36
|
-
### Port
|
|
37
|
-
|
|
38
|
-
Default port is **8989**. Persist a different port (and hot-restart the daemon onto it if one is running):
|
|
39
|
-
|
|
40
|
-
```
|
|
41
|
-
mslxdff -port 8000 # set port to 8000; restarts the daemon on 8000
|
|
42
|
-
mslxdff -d # next starts reuse the persisted port (8000)
|
|
43
|
-
```
|
|
44
|
-
|
|
45
|
-
Priority: `-port` arg > persisted port > `PORT` env > default `8989`.
|
|
46
|
-
|
|
47
|
-
### Daemon (background, stays resident)
|
|
48
|
-
|
|
49
|
-
Just run `mslxdff` (no args): if no daemon is running yet, it **starts a background
|
|
50
|
-
daemon and exits** — the command never holds your terminal, so `npx mslxdff` works
|
|
51
|
-
the same way. If a daemon is already up, the bare command shows status + help.
|
|
52
|
-
|
|
53
|
-
```
|
|
54
|
-
mslxdff # starts the background daemon (if none running); exits immediately
|
|
55
|
-
mslxdff -d # same, explicit
|
|
56
|
-
mslxdff -stop # stop it
|
|
57
|
-
```
|
|
58
|
-
|
|
59
|
-
Logs go to `~/.config/mslxdff/daemon.log`, the daemon pid to `daemon.pid` (both overridable via `MSLXDFF_DAEMON_DIR`). The daemon keeps running after your shell exits.
|
|
60
|
-
|
|
61
|
-
### Status
|
|
62
|
-
|
|
63
|
-
Running `mslxdff` with no args shows a status panel when a daemon is already up: joined groups with their members, failover targets, the free model list, the last 5 calls (model/status/latency), and the most recent error (`mslxdff -status` for status only, `mslxdff -help` for the full command reference). Call and error history are stored as JSON-lines at `calls.log` / `errors.log` in the state dir.
|
|
64
|
-
|
|
65
|
-
```
|
|
66
|
-
$ mslxdff -status
|
|
67
|
-
mslxdff v0.1.2
|
|
68
|
-
daemon: running (pid 12345)
|
|
69
|
-
endpoint: http://localhost:8989/v1
|
|
70
|
-
log dir: C:/Users/you/.config/mslxdff
|
|
71
|
-
|
|
72
|
-
models (7 free):
|
|
73
|
-
big-pickle
|
|
74
|
-
deepseek-v4-flash-free
|
|
75
|
-
...
|
|
76
|
-
|
|
77
|
-
recent calls:
|
|
78
|
-
08-03 12:00:03 deepseek-v4-flash-free 200 812ms
|
|
79
|
-
...
|
|
80
|
-
|
|
81
|
-
last error:
|
|
82
|
-
08-03 11:59:58 deepseek-v4-flash-free 429 upstream 429
|
|
83
|
-
```
|
|
84
|
-
|
|
85
|
-
### Update
|
|
86
|
-
|
|
87
|
-
```
|
|
88
|
-
mslxdff -update # install the latest published version; restarts a running daemon
|
|
89
|
-
```
|
|
90
|
-
|
|
91
|
-
### Groups (join several machines by name)
|
|
92
|
-
|
|
93
|
-
Run mslxdff on several machines (e.g. locally + a VPS) and join them into a named group — **the group name is the password**. When the local upstream fails for a model, the request is forwarded to a group member running the **same model** — your task keeps the model it asked for, just served from another machine with its own upstream quota.
|
|
94
|
-
|
|
95
|
-
```
|
|
96
|
-
# on the leader machine B (must be reachable from the others):
|
|
97
|
-
mslxdff -creategroup mygroup # group name IS the password, no address needed
|
|
98
|
-
|
|
99
|
-
# on machine A (and C, D, …) — give the leader's host; your own address is auto-registered:
|
|
100
|
-
mslxdff -addtogroup <B-ip> mygroup
|
|
101
|
-
```
|
|
102
|
-
|
|
103
|
-
- Anyone who knows the group name can join — pick something unguessable, like `mygroup@mslxd-7f3k`. The leader's first joiner seeds the leader's own entry from the address it connects from, so `-creategroup` takes no address argument (default port 8989; `-addtogroup` also accepts `host:port`).
|
|
104
|
-
- Joining registers your node with the leader (membership is keyed by your bearer token) and immediately pulls the member list into local failover targets.
|
|
105
|
-
- Membership is persisted and re-synced on every start + every `MSLXDFF_GROUP_SYNC_MS` (default 60s), so a newly joined machine becomes a failover target on all nodes automatically.
|
|
106
|
-
- Local-first: every request tries the local upstream before any group member. On local failure, members are tried round-robin for the same model; a member that just failed is skipped for `MSLXDFF_PEER_COOLDOWN_MS` (default 30s).
|
|
107
|
-
- Forwarded requests carry a hop limit (`MSLXDFF_MAX_HOPS`, default 3) to prevent forwarding loops, and a model-lock so the member doesn't switch models. A→B→C chaining works as long as each member knows the others.
|
|
108
|
-
- Wrong group names (or wrong tokens) are counted per source IP: `MSLXDFF_BAN_THRESHOLD` (default 5) failures ban the IP for `MSLXDFF_BAN_WINDOW_MS` (default 48h). `mslxdff -resetban [ip]` clears bans (all, or one IP).
|
|
109
|
-
- `mslxdff -group sync` pulls once manually; `mslxdff -group leave <name>` detaches from a group and clears its members; `mslxdff -group list` shows groups. See `docs/adr/0005-peer-mesh.md`.
|
|
110
|
-
|
|
111
|
-
```
|
|
112
|
-
mslxdff -showtoken # print the current token (creates one on first use)
|
|
113
|
-
mslxdff -refresh-token # rotate it (prints the new token, does not start the server)
|
|
114
|
-
```
|
|
115
|
-
|
|
116
|
-
## Client configuration
|
|
117
|
-
|
|
118
|
-
Point any OpenAI-compatible client at the endpoint with the token:
|
|
119
|
-
|
|
120
|
-
```
|
|
121
|
-
Endpoint: http://localhost:8989/v1
|
|
122
|
-
API Key: <the bearer token> (sent as Authorization: Bearer <token>)
|
|
123
|
-
Model: oc/deepseek-v4-flash-free (the oc/ prefix is optional)
|
|
124
|
-
```
|
|
125
|
-
|
|
126
|
-
<x-model list>
|
|
127
|
-
|
|
128
|
-
```
|
|
129
|
-
$ curl -H "Authorization: Bearer <token>" http://localhost:8989/v1/models
|
|
130
|
-
{"object":"list","data":[{"id":"big-pickle",...},{"id":"deepseek-v4-flash-free",...}, ...]}
|
|
131
|
-
</x-model list>
|
|
132
|
-
|
|
133
|
-
## Environment variables
|
|
134
|
-
|
|
135
|
-
| Variable | Default | Purpose |
|
|
136
|
-
|---|---|---|
|
|
137
|
-
| `PORT` | `8989` | listen port (used when no `-port` arg and no persisted port) |
|
|
138
|
-
| `MSLXDFF_STATE_FILE` | `~/.config/mslxdff/state.json` | token/port state file (mode 0600) |
|
|
139
|
-
| `MSLXDFF_DAEMON_DIR` | `~/.config/mslxdff` | daemon pid + log directory |
|
|
140
|
-
| `UPSTREAM_BASE_URL` | `https://opencode.ai` | upstream base |
|
|
141
|
-
| `UPSTREAM_AUTH_TOKEN` | `public` | upstream `Authorization: Bearer <…>` value |
|
|
142
|
-
| `UPSTREAM_CONNECT_TIMEOUT_MS` | `30000` | upstream connect timeout |
|
|
143
|
-
| `LOG_LEVEL` | `info` | (reserved) |
|
|
144
|
-
| `MODELS_REFRESH_MS` | `7200000` | background model-list refresh interval (2h) |
|
|
145
|
-
| `MSLXDFF_MODEL_COOLDOWN_MS` | `60000` | fallback cooldown after a model error |
|
|
146
|
-
| `MSLXDFF_PEER_COOLDOWN_MS` | `30000` | peer failover cooldown |
|
|
147
|
-
| `MSLXDFF_GROUP_SYNC_MS` | `60000` | group membership sync interval |
|
|
148
|
-
| `MSLXDFF_MAX_HOPS` | `3` | max peer-forwarding depth |
|
|
149
|
-
| `MSLXDFF_BAN_THRESHOLD` | `5` | failed joins before an IP is banned |
|
|
150
|
-
| `MSLXDFF_BAN_WINDOW_MS` | `172800000` | ban duration after too many join failures (48h) |
|
|
151
|
-
|
|
152
|
-
## Clients
|
|
153
|
-
|
|
154
|
-
Point your OpenAI client at `http://<host>:8989/v1` (or the equivalent config seen above). Works for streaming and non-streaming chat completions.
|
|
155
|
-
|
|
156
|
-
## Development
|
|
157
|
-
|
|
158
|
-
```
|
|
159
|
-
npm test # node --test, no network access
|
|
160
|
-
```
|
|
161
|
-
|
|
162
|
-
The reference implementation is 9Router v0.5.45 (`/root/9router`); see `CLAUDE.md`, `CONTEXT.md`, and `docs/adr/` for the contract and the decisions behind it.
|
|
1
|
+
# mslxdff
|
|
2
|
+
|
|
3
|
+
测试项目,请勿使用。
|
|
4
|
+
|
|
5
|
+
Zero runtime dependencies: Node ≥ 20, built-in `node:http`, `node:crypto`, `node:test`.
|
|
6
|
+
|
|
7
|
+
## What it does
|
|
8
|
+
|
|
9
|
+
- `POST /v1/chat/completions` — forwards your OpenAI-format request to the upstream (with reasoning-content injection for thinking-mode DeepSeek/Kimi models), streams SSE back chunk-by-chunk, or passes through JSON for non-streaming calls.
|
|
10
|
+
- `GET /v1/models` — the ~7 free models (`*-free` plus `big-pickle`), filtered from the full upstream list, cached and refreshed in the background every 2 hours.
|
|
11
|
+
- `GET /health` — public liveness check.
|
|
12
|
+
|
|
13
|
+
### `auto` model
|
|
14
|
+
|
|
15
|
+
Omitting `model` (or passing `"auto"`) picks a free model automatically, DeepSeek first. On an upstream error the next candidate is tried in the same request; each model's last-error timestamp is recorded (persisted in the state file) so the most reliably-available model is preferred next time.
|
|
16
|
+
|
|
17
|
+
### Fallback for a specific model
|
|
18
|
+
|
|
19
|
+
Pointing at a specific model (e.g. `deepseek-v4-flash-free`) still gets failover: if that model errors, the request automatically falls through to the next free model so your task isn't interrupted, and the error is recorded. The model enters a cooldown window (default 60s, `MSLXDFF_MODEL_COOLDOWN_MS`); during cooldown the backup models are used directly, and afterwards your model is tried again — once it succeeds it keeps being used until it errors again.
|
|
20
|
+
|
|
21
|
+
## Install & run
|
|
22
|
+
|
|
23
|
+
```
|
|
24
|
+
npm install # no deps actually fetched; just links the bin
|
|
25
|
+
mslxdff # or: node bin/mslxdff.js
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
First run generates a bearer token, writes it to the state file, and prints it:
|
|
29
|
+
|
|
30
|
+
```
|
|
31
|
+
mslxdff listening on http://localhost:8989
|
|
32
|
+
auth token: 9b5de021e914...
|
|
33
|
+
endpoint: http://localhost:8989/v1
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
### Port
|
|
37
|
+
|
|
38
|
+
Default port is **8989**. Persist a different port (and hot-restart the daemon onto it if one is running):
|
|
39
|
+
|
|
40
|
+
```
|
|
41
|
+
mslxdff -port 8000 # set port to 8000; restarts the daemon on 8000
|
|
42
|
+
mslxdff -d # next starts reuse the persisted port (8000)
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
Priority: `-port` arg > persisted port > `PORT` env > default `8989`.
|
|
46
|
+
|
|
47
|
+
### Daemon (background, stays resident)
|
|
48
|
+
|
|
49
|
+
Just run `mslxdff` (no args): if no daemon is running yet, it **starts a background
|
|
50
|
+
daemon and exits** — the command never holds your terminal, so `npx mslxdff` works
|
|
51
|
+
the same way. If a daemon is already up, the bare command shows status + help.
|
|
52
|
+
|
|
53
|
+
```
|
|
54
|
+
mslxdff # starts the background daemon (if none running); exits immediately
|
|
55
|
+
mslxdff -d # same, explicit
|
|
56
|
+
mslxdff -stop # stop it
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
Logs go to `~/.config/mslxdff/daemon.log`, the daemon pid to `daemon.pid` (both overridable via `MSLXDFF_DAEMON_DIR`). The daemon keeps running after your shell exits.
|
|
60
|
+
|
|
61
|
+
### Status
|
|
62
|
+
|
|
63
|
+
Running `mslxdff` with no args shows a status panel when a daemon is already up: joined groups with their members, failover targets, the free model list, the last 5 calls (model/status/latency), and the most recent error (`mslxdff -status` for status only, `mslxdff -help` for the full command reference). Call and error history are stored as JSON-lines at `calls.log` / `errors.log` in the state dir.
|
|
64
|
+
|
|
65
|
+
```
|
|
66
|
+
$ mslxdff -status
|
|
67
|
+
mslxdff v0.1.2
|
|
68
|
+
daemon: running (pid 12345)
|
|
69
|
+
endpoint: http://localhost:8989/v1
|
|
70
|
+
log dir: C:/Users/you/.config/mslxdff
|
|
71
|
+
|
|
72
|
+
models (7 free):
|
|
73
|
+
big-pickle
|
|
74
|
+
deepseek-v4-flash-free
|
|
75
|
+
...
|
|
76
|
+
|
|
77
|
+
recent calls:
|
|
78
|
+
08-03 12:00:03 deepseek-v4-flash-free 200 812ms
|
|
79
|
+
...
|
|
80
|
+
|
|
81
|
+
last error:
|
|
82
|
+
08-03 11:59:58 deepseek-v4-flash-free 429 upstream 429
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
### Update
|
|
86
|
+
|
|
87
|
+
```
|
|
88
|
+
mslxdff -update # install the latest published version; restarts a running daemon
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
### Groups (join several machines by name)
|
|
92
|
+
|
|
93
|
+
Run mslxdff on several machines (e.g. locally + a VPS) and join them into a named group — **the group name is the password**. When the local upstream fails for a model, the request is forwarded to a group member running the **same model** — your task keeps the model it asked for, just served from another machine with its own upstream quota.
|
|
94
|
+
|
|
95
|
+
```
|
|
96
|
+
# on the leader machine B (must be reachable from the others):
|
|
97
|
+
mslxdff -creategroup mygroup # group name IS the password, no address needed
|
|
98
|
+
|
|
99
|
+
# on machine A (and C, D, …) — give the leader's host; your own address is auto-registered:
|
|
100
|
+
mslxdff -addtogroup <B-ip> mygroup
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
- Anyone who knows the group name can join — pick something unguessable, like `mygroup@mslxd-7f3k`. The leader's first joiner seeds the leader's own entry from the address it connects from, so `-creategroup` takes no address argument (default port 8989; `-addtogroup` also accepts `host:port`).
|
|
104
|
+
- Joining registers your node with the leader (membership is keyed by your bearer token) and immediately pulls the member list into local failover targets.
|
|
105
|
+
- Membership is persisted and re-synced on every start + every `MSLXDFF_GROUP_SYNC_MS` (default 60s), so a newly joined machine becomes a failover target on all nodes automatically.
|
|
106
|
+
- Local-first: every request tries the local upstream before any group member. On local failure, members are tried round-robin for the same model; a member that just failed is skipped for `MSLXDFF_PEER_COOLDOWN_MS` (default 30s).
|
|
107
|
+
- Forwarded requests carry a hop limit (`MSLXDFF_MAX_HOPS`, default 3) to prevent forwarding loops, and a model-lock so the member doesn't switch models. A→B→C chaining works as long as each member knows the others.
|
|
108
|
+
- Wrong group names (or wrong tokens) are counted per source IP: `MSLXDFF_BAN_THRESHOLD` (default 5) failures ban the IP for `MSLXDFF_BAN_WINDOW_MS` (default 48h). `mslxdff -resetban [ip]` clears bans (all, or one IP).
|
|
109
|
+
- `mslxdff -group sync` pulls once manually; `mslxdff -group leave <name>` detaches from a group and clears its members; `mslxdff -group list` shows groups. See `docs/adr/0005-peer-mesh.md`.
|
|
110
|
+
|
|
111
|
+
```
|
|
112
|
+
mslxdff -showtoken # print the current token (creates one on first use)
|
|
113
|
+
mslxdff -refresh-token # rotate it (prints the new token, does not start the server)
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
## Client configuration
|
|
117
|
+
|
|
118
|
+
Point any OpenAI-compatible client at the endpoint with the token:
|
|
119
|
+
|
|
120
|
+
```
|
|
121
|
+
Endpoint: http://localhost:8989/v1
|
|
122
|
+
API Key: <the bearer token> (sent as Authorization: Bearer <token>)
|
|
123
|
+
Model: oc/deepseek-v4-flash-free (the oc/ prefix is optional)
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
<x-model list>
|
|
127
|
+
|
|
128
|
+
```
|
|
129
|
+
$ curl -H "Authorization: Bearer <token>" http://localhost:8989/v1/models
|
|
130
|
+
{"object":"list","data":[{"id":"big-pickle",...},{"id":"deepseek-v4-flash-free",...}, ...]}
|
|
131
|
+
</x-model list>
|
|
132
|
+
|
|
133
|
+
## Environment variables
|
|
134
|
+
|
|
135
|
+
| Variable | Default | Purpose |
|
|
136
|
+
|---|---|---|
|
|
137
|
+
| `PORT` | `8989` | listen port (used when no `-port` arg and no persisted port) |
|
|
138
|
+
| `MSLXDFF_STATE_FILE` | `~/.config/mslxdff/state.json` | token/port state file (mode 0600) |
|
|
139
|
+
| `MSLXDFF_DAEMON_DIR` | `~/.config/mslxdff` | daemon pid + log directory |
|
|
140
|
+
| `UPSTREAM_BASE_URL` | `https://opencode.ai` | upstream base |
|
|
141
|
+
| `UPSTREAM_AUTH_TOKEN` | `public` | upstream `Authorization: Bearer <…>` value |
|
|
142
|
+
| `UPSTREAM_CONNECT_TIMEOUT_MS` | `30000` | upstream connect timeout |
|
|
143
|
+
| `LOG_LEVEL` | `info` | (reserved) |
|
|
144
|
+
| `MODELS_REFRESH_MS` | `7200000` | background model-list refresh interval (2h) |
|
|
145
|
+
| `MSLXDFF_MODEL_COOLDOWN_MS` | `60000` | fallback cooldown after a model error |
|
|
146
|
+
| `MSLXDFF_PEER_COOLDOWN_MS` | `30000` | peer failover cooldown |
|
|
147
|
+
| `MSLXDFF_GROUP_SYNC_MS` | `60000` | group membership sync interval |
|
|
148
|
+
| `MSLXDFF_MAX_HOPS` | `3` | max peer-forwarding depth |
|
|
149
|
+
| `MSLXDFF_BAN_THRESHOLD` | `5` | failed joins before an IP is banned |
|
|
150
|
+
| `MSLXDFF_BAN_WINDOW_MS` | `172800000` | ban duration after too many join failures (48h) |
|
|
151
|
+
|
|
152
|
+
## Clients
|
|
153
|
+
|
|
154
|
+
Point your OpenAI client at `http://<host>:8989/v1` (or the equivalent config seen above). Works for streaming and non-streaming chat completions.
|
|
155
|
+
|
|
156
|
+
## Development
|
|
157
|
+
|
|
158
|
+
```
|
|
159
|
+
npm test # node --test, no network access
|
|
160
|
+
```
|
|
161
|
+
|
|
162
|
+
The reference implementation is 9Router v0.5.45 (`/root/9router`); see `CLAUDE.md`, `CONTEXT.md`, and `docs/adr/` for the contract and the decisions behind it.
|
package/bin/mslxdff.js
CHANGED
|
@@ -8,8 +8,10 @@ import { DEFAULT_PORT, defaultStateFile } from "../src/state.js";
|
|
|
8
8
|
import { createRouter } from "../src/routes.js";
|
|
9
9
|
import { createUpstreamClient } from "../src/upstream.js";
|
|
10
10
|
import { createModelsService } from "../src/models.js";
|
|
11
|
-
import { loadToken, refreshToken, setPort, getPort, loadGroupsJoined, saveGroupsJoined, loadModelErrors, savePreferredModel } from "../src/state.js";
|
|
11
|
+
import { loadToken, refreshToken, setPort, getPort, loadGroupsJoined, saveGroupsJoined, loadModelErrors, savePreferredModel, loadPreferredModel, loadModelPicks, saveModelPicks } from "../src/state.js";
|
|
12
12
|
import { getPreferredModel } from "../src/auto.js";
|
|
13
|
+
import { normalizeModel } from "../src/reasoning.js";
|
|
14
|
+
import { syncToWorkbuddy, workbuddyModelsPath } from "../src/sync-workbuddy.js";
|
|
13
15
|
import { startDaemon, stopDaemon, writePid, pidFile, logFile, readPid, readPidVersion, isPidAlive } from "../src/daemon.js";
|
|
14
16
|
import { createAutoSelector } from "../src/auto.js";
|
|
15
17
|
import { createPeersService } from "../src/peers.js";
|
|
@@ -183,60 +185,116 @@ if (args.includes("-model") || args.includes("-models")) {
|
|
|
183
185
|
if (sub === "set" && args[idx + 2]) {
|
|
184
186
|
const id = args[idx + 2];
|
|
185
187
|
savePreferredModel(id);
|
|
188
|
+
const picks = [...new Set([...loadModelPicks(), id])];
|
|
189
|
+
saveModelPicks(picks);
|
|
186
190
|
console.log(`default model set to: ${id} (daemon hot-reloads on next request)`);
|
|
191
|
+
console.log(`picked: ${picks.join(", ") || "(none)"} (auto will pick within these)`);
|
|
192
|
+
process.exit(0);
|
|
193
|
+
}
|
|
194
|
+
// -model pick <id> | -model unpick <id> | -model picks : 常用模型勾选集管理(非 TTY 用)
|
|
195
|
+
if (sub === "pick" && args[idx + 2] && args[idx + 2] !== "clear") {
|
|
196
|
+
const picks = [...new Set([...loadModelPicks(), args[idx + 2]])];
|
|
197
|
+
saveModelPicks(picks);
|
|
198
|
+
console.log(`picked: ${picks.join(", ") || "(none)"} (auto will pick within these)`);
|
|
199
|
+
process.exit(0);
|
|
200
|
+
}
|
|
201
|
+
if (sub === "pick" && args[idx + 2] === "clear") {
|
|
202
|
+
saveModelPicks([]);
|
|
203
|
+
console.log("picks cleared — auto uses the full model list again");
|
|
204
|
+
process.exit(0);
|
|
205
|
+
}
|
|
206
|
+
if (sub === "unpick" && args[idx + 2]) {
|
|
207
|
+
const picks = loadModelPicks().filter((x) => x !== args[idx + 2]);
|
|
208
|
+
saveModelPicks(picks);
|
|
209
|
+
console.log(`picked: ${picks.join(", ") || "(none)"}${picks.length === 0 ? " (auto uses full list)" : ""}`);
|
|
210
|
+
process.exit(0);
|
|
211
|
+
}
|
|
212
|
+
if (sub === "picks") {
|
|
213
|
+
const picks = loadModelPicks();
|
|
214
|
+
if (!picks.length) {
|
|
215
|
+
console.log("no picks — auto uses the full model list");
|
|
216
|
+
} else {
|
|
217
|
+
console.log(`${picks.length} picked model(s), auto only selects within these:`);
|
|
218
|
+
}
|
|
219
|
+
for (const id of picks) console.log(` ${id}`);
|
|
187
220
|
process.exit(0);
|
|
188
221
|
}
|
|
189
222
|
if (sub !== undefined && sub !== "list") {
|
|
190
|
-
console.error("usage: mslxdff -models (interactive
|
|
223
|
+
console.error("usage: mslxdff -models (interactive multi-pick) | mslxdff -model list | mslxdff -model set <id> | mslxdff -model pick <id> | mslxdff -model unpick <id> | mslxdff -model pick clear | mslxdff -model picks | mslxdff -model status | mslxdff -model refresh");
|
|
191
224
|
process.exit(1);
|
|
192
225
|
}
|
|
193
226
|
const cacheFile = join(logDir(), "models.json");
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
const cached = readModelsCache(cacheFile);
|
|
198
|
-
if (cached) {
|
|
199
|
-
ids = (cached.data || []).map((m) => m.id).filter(Boolean);
|
|
200
|
-
cachedAt = cached.cachedAt || null;
|
|
201
|
-
} else {
|
|
227
|
+
// 主动刷新:每次执行都尝试拉取上游最新列表(4s 超时),成功则更新缓存,失败则回退到 stale
|
|
228
|
+
async function tryRefreshModels() {
|
|
229
|
+
try {
|
|
202
230
|
const models = createModelsService({
|
|
203
231
|
baseUrl: process.env.UPSTREAM_BASE_URL || "https://opencode.ai",
|
|
204
232
|
headers: createUpstreamClient({}).headers,
|
|
205
233
|
refreshMs: 0,
|
|
206
234
|
cacheFile,
|
|
207
235
|
});
|
|
208
|
-
|
|
209
|
-
|
|
236
|
+
// 4s 超时,避免阻塞
|
|
237
|
+
const list = await Promise.race([
|
|
238
|
+
models.get(),
|
|
239
|
+
new Promise((_, rej) => setTimeout(() => rej(new Error("refresh timeout")), 4000)),
|
|
240
|
+
]);
|
|
241
|
+
return list;
|
|
242
|
+
} catch {
|
|
243
|
+
return null;
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
try {
|
|
247
|
+
let ids = [];
|
|
248
|
+
let cachedAt = null;
|
|
249
|
+
let refreshed = null;
|
|
250
|
+
// 每次都主动尝试刷新
|
|
251
|
+
refreshed = await tryRefreshModels();
|
|
252
|
+
if (refreshed?.data) {
|
|
253
|
+
ids = (refreshed.data || []).map((m) => m.id).filter(Boolean);
|
|
254
|
+
cachedAt = refreshed.cachedAt || Date.now();
|
|
255
|
+
} else {
|
|
256
|
+
const cached = readModelsCache(cacheFile);
|
|
257
|
+
if (cached) {
|
|
258
|
+
ids = (cached.data || []).map((m) => m.id).filter(Boolean);
|
|
259
|
+
cachedAt = cached.cachedAt || null;
|
|
260
|
+
} else {
|
|
261
|
+
// 无缓存且刷新失败,尝试一次兜底 fetch(已在 tryRefreshModels 中尝试过,此处直接报错)
|
|
262
|
+
throw new Error("no cached models and refresh failed");
|
|
263
|
+
}
|
|
210
264
|
}
|
|
211
265
|
if (!ids.length) {
|
|
212
266
|
console.log("no models available — try: mslxdff -model refresh");
|
|
213
267
|
process.exit(0);
|
|
214
268
|
}
|
|
215
|
-
// TTY
|
|
269
|
+
// TTY:交互式多选勾选常用模型(空格勾选,Enter 保存);非 TTY(管道/脚本):保持纯列表并标注勾选
|
|
216
270
|
if (process.stdin.isTTY && process.stdout.isTTY) {
|
|
217
271
|
const statuses = loadModelErrors();
|
|
218
272
|
const current = getPreferredModel();
|
|
273
|
+
const pickedIds = loadModelPicks();
|
|
219
274
|
const items = ids.map((id) => {
|
|
220
275
|
const e = statuses[id];
|
|
221
276
|
return {
|
|
222
277
|
id,
|
|
223
278
|
status: typeof e === "number" ? "error" : e?.status || "normal",
|
|
224
279
|
current: id === current,
|
|
280
|
+
picked: pickedIds.includes(id),
|
|
225
281
|
};
|
|
226
282
|
});
|
|
227
|
-
const
|
|
228
|
-
if (!
|
|
229
|
-
console.log("cancelled —
|
|
283
|
+
const result = await pickInteractiveMulti(items, new Set(pickedIds), Math.max(0, items.findIndex((x) => x.current)));
|
|
284
|
+
if (!result) {
|
|
285
|
+
console.log("cancelled — picks unchanged");
|
|
230
286
|
process.exit(0);
|
|
231
287
|
}
|
|
232
|
-
|
|
233
|
-
console.log(`
|
|
288
|
+
saveModelPicks([...result]);
|
|
289
|
+
console.log(`saved ${result.size} picked model(s): ${[...result].join(", ") || "(none — auto uses full list)"}`);
|
|
234
290
|
process.exit(0);
|
|
235
291
|
}
|
|
236
292
|
const at = cachedAt ? ` (cached ${new Date(cachedAt).toISOString().slice(0, 16).replace("T", " ")})` : "";
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
console.log(
|
|
293
|
+
const pickedIds = loadModelPicks();
|
|
294
|
+
const mark = (id) => (pickedIds.includes(id) ? "*" : " ");
|
|
295
|
+
console.log(`${ids.length} free model(s)${at} (${pickedIds.length} picked, * = picked):`);
|
|
296
|
+
for (const id of ids) console.log(` ${mark(id)} ${id}`);
|
|
297
|
+
console.log(`\npicked only constrains auto; manage with: mslxdff -models (TTY) | mslxdff -model pick <id> | mslxdff -model unpick <id> | mslxdff -model pick clear`);
|
|
240
298
|
} catch (err) {
|
|
241
299
|
console.error(`could not fetch models: ${String(err?.message || err)}`);
|
|
242
300
|
process.exit(1);
|
|
@@ -244,6 +302,74 @@ if (args.includes("-model") || args.includes("-models")) {
|
|
|
244
302
|
process.exit(0);
|
|
245
303
|
}
|
|
246
304
|
|
|
305
|
+
// -setto workbuddy [modelId]: set default model and sync to WorkBuddy models.json
|
|
306
|
+
if (args.includes("-setto") || args.includes("--setto")) {
|
|
307
|
+
const idx = args.findIndex((x) => x === "-setto" || x === "--setto");
|
|
308
|
+
const target = args[idx + 1];
|
|
309
|
+
if (target !== "workbuddy") {
|
|
310
|
+
console.error("usage: mslxdff -setto workbuddy [modelId]");
|
|
311
|
+
process.exit(1);
|
|
312
|
+
}
|
|
313
|
+
const raw = args[idx + 2] && !String(args[idx + 2]).startsWith("-") ? String(args[idx + 2]).trim() : null;
|
|
314
|
+
let id;
|
|
315
|
+
if (raw) {
|
|
316
|
+
if (raw === "auto" || !raw) {
|
|
317
|
+
console.error("modelId 不能为 auto 或空");
|
|
318
|
+
process.exit(1);
|
|
319
|
+
}
|
|
320
|
+
const norm = normalizeModel(raw);
|
|
321
|
+
if (!norm) {
|
|
322
|
+
console.error("modelId 不能为空");
|
|
323
|
+
process.exit(1);
|
|
324
|
+
}
|
|
325
|
+
savePreferredModel(norm);
|
|
326
|
+
console.log(`default model set to: ${norm} (daemon hot-reloads on next request)`);
|
|
327
|
+
id = norm;
|
|
328
|
+
} else {
|
|
329
|
+
id = loadPreferredModel() || getPreferredModel();
|
|
330
|
+
if (!id) {
|
|
331
|
+
console.error("no preferred model set; use: mslxdff -setto workbuddy <modelId>");
|
|
332
|
+
process.exit(1);
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
// 主动刷新模型列表(每次 -setto 都尝试,保证 deepseek 等最新模型可见;失败不阻断同步)
|
|
336
|
+
try {
|
|
337
|
+
const cacheFile = join(logDir(), "models.json");
|
|
338
|
+
const models = createModelsService({
|
|
339
|
+
baseUrl: process.env.UPSTREAM_BASE_URL || "https://opencode.ai",
|
|
340
|
+
headers: createUpstreamClient({}).headers,
|
|
341
|
+
refreshMs: 0,
|
|
342
|
+
cacheFile,
|
|
343
|
+
});
|
|
344
|
+
const fresh = await Promise.race([
|
|
345
|
+
models.get(),
|
|
346
|
+
new Promise((_, rej) => setTimeout(() => rej(new Error("refresh timeout")), 4000)),
|
|
347
|
+
]);
|
|
348
|
+
if (fresh?.data?.length) {
|
|
349
|
+
const ids = fresh.data.map((m) => m.id);
|
|
350
|
+
if (!ids.includes(id)) {
|
|
351
|
+
console.log(`warn: "${id}" not in current free list (${ids.length} models), still syncing to WorkBuddy`);
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
} catch {
|
|
355
|
+
// refresh failed, still proceed with sync using stale/cached list
|
|
356
|
+
}
|
|
357
|
+
try {
|
|
358
|
+
const { token } = await loadToken();
|
|
359
|
+
const persisted = getPort();
|
|
360
|
+
const envPort = Number(process.env.MSLXDFF_PORT);
|
|
361
|
+
const port = persisted !== null ? persisted : (Number.isInteger(envPort) && envPort > 0 ? envPort : 8989);
|
|
362
|
+
const file = workbuddyModelsPath();
|
|
363
|
+
const r = await syncToWorkbuddy({ id, token, port, file });
|
|
364
|
+
console.log(`synced to WorkBuddy: ${r.action} "${id}" @ ${file}`);
|
|
365
|
+
console.log(` url: http://127.0.0.1:${port}/v1/chat/completions`);
|
|
366
|
+
} catch (err) {
|
|
367
|
+
console.error(`failed to sync to WorkBuddy: ${String(err?.message || err)}`);
|
|
368
|
+
process.exit(1);
|
|
369
|
+
}
|
|
370
|
+
process.exit(0);
|
|
371
|
+
}
|
|
372
|
+
|
|
247
373
|
// 交互式选择器:↑/↓ 移动,Enter 确认,q/Esc 取消;ANSI 原地重绘
|
|
248
374
|
async function pickInteractive(items, startCursor = 0) {
|
|
249
375
|
const { renderChooser, renderChooserHelp, parseKey } = await import("../src/chooser.js");
|
|
@@ -284,6 +410,53 @@ async function pickInteractive(items, startCursor = 0) {
|
|
|
284
410
|
});
|
|
285
411
|
}
|
|
286
412
|
|
|
413
|
+
// 多选勾选:↑/↓ 移动,Space 勾选/取消,Enter 保存,q/Esc 取消(返回 Set 或 null)
|
|
414
|
+
async function pickInteractiveMulti(items, initialPicked = new Set(), startCursor = 0) {
|
|
415
|
+
const { renderChooser, renderChooserHelp, parseKey } = await import("../src/chooser.js");
|
|
416
|
+
let cursor = Math.min(Math.max(startCursor, 0), items.length - 1);
|
|
417
|
+
const picked = new Set(items.filter((it) => initialPicked.has(it.id)).map((it) => it.id));
|
|
418
|
+
const draw = () => {
|
|
419
|
+
const rows = items.map((it, i) => ({ ...it, picked: picked.has(it.id) }));
|
|
420
|
+
const lines = [...renderChooser(rows, cursor, { multi: true }), ...renderChooserHelp(true)];
|
|
421
|
+
process.stdout.write("\x1b[G\x1b[J" + lines.join("\n"));
|
|
422
|
+
};
|
|
423
|
+
draw();
|
|
424
|
+
return new Promise((resolve) => {
|
|
425
|
+
const wasRaw = process.stdin.isRaw;
|
|
426
|
+
process.stdin.setRawMode(true);
|
|
427
|
+
process.stdin.resume();
|
|
428
|
+
process.stdin.setEncoding("utf8");
|
|
429
|
+
const cleanup = () => {
|
|
430
|
+
process.stdin.removeListener("data", onData);
|
|
431
|
+
process.stdin.setRawMode(false);
|
|
432
|
+
process.stdin.pause();
|
|
433
|
+
process.stdout.write("\n");
|
|
434
|
+
};
|
|
435
|
+
const onData = (chunk) => {
|
|
436
|
+
const key = parseKey(String(chunk));
|
|
437
|
+
if (key === "up") {
|
|
438
|
+
cursor = (cursor - 1 + items.length) % items.length;
|
|
439
|
+
draw();
|
|
440
|
+
} else if (key === "down") {
|
|
441
|
+
cursor = (cursor + 1) % items.length;
|
|
442
|
+
draw();
|
|
443
|
+
} else if (key === "space") {
|
|
444
|
+
const id = items[cursor].id;
|
|
445
|
+
if (picked.has(id)) picked.delete(id);
|
|
446
|
+
else picked.add(id);
|
|
447
|
+
draw();
|
|
448
|
+
} else if (key === "enter") {
|
|
449
|
+
cleanup();
|
|
450
|
+
resolve(new Set(picked));
|
|
451
|
+
} else if (key === "cancel") {
|
|
452
|
+
cleanup();
|
|
453
|
+
resolve(null);
|
|
454
|
+
}
|
|
455
|
+
};
|
|
456
|
+
process.stdin.on("data", onData);
|
|
457
|
+
});
|
|
458
|
+
}
|
|
459
|
+
|
|
287
460
|
// -debug: stop the background daemon and run the server in THIS terminal
|
|
288
461
|
// (foreground), printing every event to stdout in real time via the in-memory
|
|
289
462
|
// event bus — no filesystem polling. Ctrl+C / SIGTERM restarts the daemon in
|
|
@@ -785,6 +958,7 @@ const models = createModelsService({
|
|
|
785
958
|
const auto = createAutoSelector({
|
|
786
959
|
cooldownMs: modelCooldownMs(),
|
|
787
960
|
slowCooldownMs: slowCooldownMs(),
|
|
961
|
+
file: defaultStateFile(),
|
|
788
962
|
loadCandidates: async () => {
|
|
789
963
|
try {
|
|
790
964
|
return (await models.get()).data.map((m) => m.id);
|
|
@@ -873,6 +1047,11 @@ if (created) {
|
|
|
873
1047
|
console.log(`auth token: ${token}`);
|
|
874
1048
|
}
|
|
875
1049
|
console.log(`endpoint: http://${host}:${addr.port}/v1`);
|
|
1050
|
+
try {
|
|
1051
|
+
const { hedgeDelayMs } = await import("../src/routes/hedge.js");
|
|
1052
|
+
const hd = hedgeDelayMs();
|
|
1053
|
+
console.log(`hedge: ${hd ? `${hd}ms` : "off"} (MSLXDFF_HEDGE_DELAY_MS)`);
|
|
1054
|
+
} catch {}
|
|
876
1055
|
|
|
877
1056
|
// Periodically pull the freshest member lists for every joined group so a
|
|
878
1057
|
// new member becomes a failover peer on all nodes without manual re-joining.
|
|
@@ -1177,6 +1356,7 @@ Usage:
|
|
|
1177
1356
|
mslxdff -update update mslxdff to the latest published version
|
|
1178
1357
|
mslxdff -showtoken print the current auth token
|
|
1179
1358
|
mslxdff -refresh-token rotate the auth token (prints the new one)
|
|
1359
|
+
mslxdff -setto workbuddy [modelId] set default model and sync to WorkBuddy models.json (insert or update 127.0.0.1/v1 entry)
|
|
1180
1360
|
mslxdff -creategroup <name> create a group on this node (the group name is the password)
|
|
1181
1361
|
mslxdff -addtogroup <leader-host> <name> [--broadband] join a group via its leader host (default port 8989) — broadband: 宽带动态IP成员(经Leader中继,无需公网入站,默认127.0.0.1)
|
|
1182
1362
|
mslxdff -group sync pull the freshest member list for all joined groups
|
|
@@ -1203,6 +1383,7 @@ Environment:
|
|
|
1203
1383
|
MSLXDFF_MAX_HOPS max peer-forwarding depth (default 3)
|
|
1204
1384
|
MSLXDFF_BAN_THRESHOLD failed joins before an ip is banned (default 5)
|
|
1205
1385
|
MSLXDFF_BAN_WINDOW_MS ban duration after too many failures (default 48h)
|
|
1386
|
+
MSLXDFF_HEDGE_DELAY_MS hedge peer race when local stream first chunk slow (default 1000, 0/off to disable)
|
|
1206
1387
|
MSLXDFF_AUTO_UPDATE auto-update: hourly by default, 0/off/false to disable, 1/true or ms
|
|
1207
1388
|
MSLXDFF_AUTO_UPDATE_MS same as above, explicit ms (overrides AUTO_UPDATE)
|
|
1208
1389
|
`);
|