onbuzz 6.3.0 → 6.3.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.
- package/package.json +1 -1
- package/src/interfaces/webServer.js +57 -1
- package/src/services/__tests__/modelQuirks.test.js +55 -1
- package/src/services/__tests__/serviceRegistry.test.js +23 -1
- package/src/services/aiService.js +29 -3
- package/src/services/modelQuirks.js +76 -10
- package/src/services/serviceRegistry.js +20 -0
package/package.json
CHANGED
|
@@ -441,6 +441,11 @@ class WebServer {
|
|
|
441
441
|
}
|
|
442
442
|
}
|
|
443
443
|
|
|
444
|
+
// Per-process identity, echoed by /api/health so a caller (the Electron
|
|
445
|
+
// shell above all) can tell OUR server from another program that happens
|
|
446
|
+
// to answer on the same port. See startServer() → _verifyOwnPort().
|
|
447
|
+
this.instanceId = crypto.randomUUID();
|
|
448
|
+
|
|
444
449
|
// Express app
|
|
445
450
|
this.app = express();
|
|
446
451
|
this.server = createServer(this.app);
|
|
@@ -1066,12 +1071,14 @@ class WebServer {
|
|
|
1066
1071
|
res.json({
|
|
1067
1072
|
status: 'healthy',
|
|
1068
1073
|
version: packageJson.version || '1.0.0',
|
|
1074
|
+
instance: this.instanceId,
|
|
1069
1075
|
timestamp: new Date().toISOString()
|
|
1070
1076
|
});
|
|
1071
1077
|
} catch {
|
|
1072
1078
|
res.json({
|
|
1073
1079
|
status: 'healthy',
|
|
1074
1080
|
version: '1.0.0',
|
|
1081
|
+
instance: this.instanceId,
|
|
1075
1082
|
timestamp: new Date().toISOString()
|
|
1076
1083
|
});
|
|
1077
1084
|
}
|
|
@@ -7834,6 +7841,47 @@ h2{color:#16a34a;margin-bottom:.5rem;} p{color:#666;}</style></head>
|
|
|
7834
7841
|
}
|
|
7835
7842
|
}
|
|
7836
7843
|
|
|
7844
|
+
// A successful bind is not proof we OWN the port: on Windows a 0.0.0.0
|
|
7845
|
+
// bind succeeds while another program holds 127.0.0.1 on the same port,
|
|
7846
|
+
// and loopback traffic goes to that program. Listen, then ask
|
|
7847
|
+
// 127.0.0.1:port/api/health for our own instance id; a foreign answer
|
|
7848
|
+
// means close and move on (dynamic port) or fail loudly (forced port).
|
|
7849
|
+
const forced = !!(envPort && !isNaN(envPort));
|
|
7850
|
+
const MAX_PORT_HOPS = 20;
|
|
7851
|
+
for (let hop = 0; hop < MAX_PORT_HOPS; hop++) {
|
|
7852
|
+
const owned = await this._listenAndVerify();
|
|
7853
|
+
if (owned) return;
|
|
7854
|
+
const foreign = this.port;
|
|
7855
|
+
if (forced) {
|
|
7856
|
+
throw new Error(`Port ${foreign} is answered by another program (it is in use on 127.0.0.1). Stop that program or start with a different LOXIA_PORT.`);
|
|
7857
|
+
}
|
|
7858
|
+
this.logger.warn('Port is answered by another program — moving to the next free port', { port: foreign, host: this.host });
|
|
7859
|
+
this.port = await findFreePort(foreign + 1, 100, this.host);
|
|
7860
|
+
}
|
|
7861
|
+
throw new Error(`Could not find a port that is not answered by another program (tried ${MAX_PORT_HOPS} from ${this.config.port || 8080})`);
|
|
7862
|
+
}
|
|
7863
|
+
|
|
7864
|
+
/**
|
|
7865
|
+
* GET our own /api/health over loopback and compare the instance id.
|
|
7866
|
+
* @private
|
|
7867
|
+
*/
|
|
7868
|
+
async _verifyOwnPort() {
|
|
7869
|
+
try {
|
|
7870
|
+
const res = await fetch(`http://127.0.0.1:${this.port}/api/health`, { signal: AbortSignal.timeout(3000) });
|
|
7871
|
+
const data = await res.json();
|
|
7872
|
+
return data?.instance === this.instanceId;
|
|
7873
|
+
} catch {
|
|
7874
|
+
return false;
|
|
7875
|
+
}
|
|
7876
|
+
}
|
|
7877
|
+
|
|
7878
|
+
/**
|
|
7879
|
+
* Listen on this.port; resolve true when the port is verified ours, false
|
|
7880
|
+
* when another program answers on it (the server is closed again in that
|
|
7881
|
+
* case). Rejects on a real bind error.
|
|
7882
|
+
* @private
|
|
7883
|
+
*/
|
|
7884
|
+
_listenAndVerify() {
|
|
7837
7885
|
return new Promise((resolve, reject) => {
|
|
7838
7886
|
// Handle bind errors (EADDRINUSE, EACCES, etc.) — must be set BEFORE listen()
|
|
7839
7887
|
const onError = (error) => {
|
|
@@ -7852,6 +7900,13 @@ h2{color:#16a34a;margin-bottom:.5rem;} p{color:#666;}</style></head>
|
|
|
7852
7900
|
this.server.listen({ port: this.port, host: this.host, exclusive: false }, async () => {
|
|
7853
7901
|
// Remove the startup error handler; replace with runtime error handler
|
|
7854
7902
|
this.server.removeListener('error', onError);
|
|
7903
|
+
|
|
7904
|
+
if (!(await this._verifyOwnPort())) {
|
|
7905
|
+
this.logger.error('Another program answers on the port we bound — not ours', { port: this.port, host: this.host });
|
|
7906
|
+
await new Promise((done) => this.server.close(() => done()));
|
|
7907
|
+
resolve(false);
|
|
7908
|
+
return;
|
|
7909
|
+
}
|
|
7855
7910
|
this.server.on('error', (error) => {
|
|
7856
7911
|
this.logger.error('HTTP server runtime error:', {
|
|
7857
7912
|
error: error.message,
|
|
@@ -7922,7 +7977,7 @@ h2{color:#16a34a;margin-bottom:.5rem;} p{color:#666;}</style></head>
|
|
|
7922
7977
|
await this.testWebSocketServer();
|
|
7923
7978
|
}, 1000);
|
|
7924
7979
|
|
|
7925
|
-
resolve();
|
|
7980
|
+
resolve(true);
|
|
7926
7981
|
});
|
|
7927
7982
|
});
|
|
7928
7983
|
}
|
|
@@ -8056,6 +8111,7 @@ h2{color:#16a34a;margin-bottom:.5rem;} p{color:#666;}</style></head>
|
|
|
8056
8111
|
isRunning: this.isRunning,
|
|
8057
8112
|
port: this.port,
|
|
8058
8113
|
host: this.host,
|
|
8114
|
+
instance: this.instanceId,
|
|
8059
8115
|
connections: this.connections.size,
|
|
8060
8116
|
sessions: this.sessions.size,
|
|
8061
8117
|
url: `http://${this.host}:${this.port}`
|
|
@@ -11,11 +11,32 @@ import { mkdtemp, rm, readFile, mkdir, writeFile } from 'node:fs/promises';
|
|
|
11
11
|
import { tmpdir } from 'node:os';
|
|
12
12
|
import { join } from 'node:path';
|
|
13
13
|
import {
|
|
14
|
-
classifyParamError, ModelQuirkStore, STRIPPABLE_PARAMS,
|
|
14
|
+
classifyParamError, classifyForcedValue, parseQuirk, ModelQuirkStore, STRIPPABLE_PARAMS, FORCEABLE_PARAMS,
|
|
15
15
|
getModelQuirkStore, _resetModelQuirkStore,
|
|
16
16
|
} from '../modelQuirks.js';
|
|
17
17
|
|
|
18
18
|
const GPT56_MESSAGE = "400 Unsupported value: 'temperature' does not support 0.7 with this model. Only the default (1) value is supported.";
|
|
19
|
+
// Fixture #2 is the VERBATIM gpt-6-astra rejection (2026-09-06, Azure gateway /llm/chat, function tools on).
|
|
20
|
+
const ASTRA_MESSAGE = "HTTP 400: Bad Request - 400 Function tools with reasoning_effort are not supported for gpt-6-astra in /v1/chat/completions. To use function tools, use /v1/responses or set reasoning_effort to 'none'.";
|
|
21
|
+
|
|
22
|
+
describe('classifyForcedValue (fixture #2: gpt-6-astra)', () => {
|
|
23
|
+
test('the astra message names the value to SEND, not a key to strip', () => {
|
|
24
|
+
expect(classifyForcedValue(new Error(ASTRA_MESSAGE))).toEqual({ param: 'reasoning_effort', value: 'none' });
|
|
25
|
+
expect(classifyParamError(new Error(ASTRA_MESSAGE))).toBeNull(); // no strippable param in it
|
|
26
|
+
expect(classifyForcedValue(ASTRA_MESSAGE)).toEqual({ param: 'reasoning_effort', value: 'none' });
|
|
27
|
+
});
|
|
28
|
+
test('only forceable params, only rejection messages; strip-class errors stay null', () => {
|
|
29
|
+
expect(classifyForcedValue(new Error("not supported: set temperature to '1'"))).toBeNull(); // temperature is strippable, not forceable
|
|
30
|
+
expect(classifyForcedValue(new Error("please set reasoning_effort to 'low'"))).toBeNull(); // no rejection wording
|
|
31
|
+
expect(classifyForcedValue(new Error(GPT56_MESSAGE))).toBeNull();
|
|
32
|
+
expect(classifyForcedValue(null)).toBeNull();
|
|
33
|
+
expect(FORCEABLE_PARAMS).toEqual(['reasoning_effort']);
|
|
34
|
+
});
|
|
35
|
+
test('parseQuirk splits forced entries and leaves bare ones', () => {
|
|
36
|
+
expect(parseQuirk('reasoning_effort=none')).toEqual({ param: 'reasoning_effort', value: 'none' });
|
|
37
|
+
expect(parseQuirk('temperature')).toEqual({ param: 'temperature', value: undefined });
|
|
38
|
+
});
|
|
39
|
+
});
|
|
19
40
|
|
|
20
41
|
describe('classifyParamError', () => {
|
|
21
42
|
test('fixture #1: the gpt-5.6-sol production error (message form)', () => {
|
|
@@ -136,6 +157,39 @@ describe('ModelQuirkStore', () => {
|
|
|
136
157
|
await store.flush(); // in-flight persist must settle before dir cleanup
|
|
137
158
|
});
|
|
138
159
|
|
|
160
|
+
test('a forced-value quirk (key=value) is SET by sanitize, persists, and re-applies after reload', async () => {
|
|
161
|
+
const store = new ModelQuirkStore({});
|
|
162
|
+
await store.load();
|
|
163
|
+
expect(store.add('gpt-6-astra', 'reasoning_effort=none')).toBe(true);
|
|
164
|
+
const payload = { temperature: 0.7, tools: [{}] };
|
|
165
|
+
expect(store.sanitize('gpt-6-astra', payload)).toEqual(['reasoning_effort']);
|
|
166
|
+
expect(payload).toEqual({ temperature: 0.7, tools: [{}], reasoning_effort: 'none' });
|
|
167
|
+
expect(store.sanitize('gpt-6-astra', payload)).toEqual([]); // already in place → nothing touched
|
|
168
|
+
await store.flush();
|
|
169
|
+
const again = new ModelQuirkStore({});
|
|
170
|
+
await again.load();
|
|
171
|
+
const p2 = {};
|
|
172
|
+
again.sanitize('gpt-6-astra', p2);
|
|
173
|
+
expect(p2).toEqual({ reasoning_effort: 'none' });
|
|
174
|
+
await again.flush();
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
test('a forced quirk can be retired: forcedEntry finds it, remove forgets it and persists', async () => {
|
|
178
|
+
const store = new ModelQuirkStore({});
|
|
179
|
+
await store.load();
|
|
180
|
+
store.add('gpt-6-astra', 'reasoning_effort=none');
|
|
181
|
+
store.add('gpt-6-astra', 'temperature');
|
|
182
|
+
expect(store.forcedEntry('gpt-6-astra', 'reasoning_effort')).toBe('reasoning_effort=none');
|
|
183
|
+
expect(store.forcedEntry('gpt-6-astra', 'temperature')).toBeNull(); // bare strip entry is not a forced value
|
|
184
|
+
expect(store.remove('gpt-6-astra', 'reasoning_effort=none')).toBe(true);
|
|
185
|
+
expect(store.remove('gpt-6-astra', 'reasoning_effort=none')).toBe(false);
|
|
186
|
+
expect([...store.get('gpt-6-astra')]).toEqual(['temperature']);
|
|
187
|
+
await store.flush();
|
|
188
|
+
const again = new ModelQuirkStore({});
|
|
189
|
+
await again.load();
|
|
190
|
+
expect([...again.get('gpt-6-astra')]).toEqual(['temperature']);
|
|
191
|
+
});
|
|
192
|
+
|
|
139
193
|
test('persist failure warns but the quirk stays active in memory', async () => {
|
|
140
194
|
const store = new ModelQuirkStore({ logger: { warn: jest.fn() } });
|
|
141
195
|
await store.load();
|
|
@@ -11,7 +11,29 @@ jest.unstable_mockModule('../portRegistry.js', () => ({
|
|
|
11
11
|
}))
|
|
12
12
|
}));
|
|
13
13
|
|
|
14
|
-
const { ServiceRegistry, ServiceStatus, registry } = await import('../serviceRegistry.js');
|
|
14
|
+
const { ServiceRegistry, ServiceStatus, registry, isPortFree, isWildcardHost } = await import('../serviceRegistry.js');
|
|
15
|
+
const net = await import('node:net');
|
|
16
|
+
|
|
17
|
+
describe('isPortFree — a wildcard port is free only if loopback is free too (Windows collision)', () => {
|
|
18
|
+
test('a port held on 127.0.0.1 is NOT free for 0.0.0.0 (the shell connects over loopback); it is free again once released', async () => {
|
|
19
|
+
const holder = net.createServer();
|
|
20
|
+
await new Promise((r) => holder.listen(0, '127.0.0.1', r));
|
|
21
|
+
const port = holder.address().port;
|
|
22
|
+
try {
|
|
23
|
+
expect(await isPortFree(port, '127.0.0.1')).toBe(false);
|
|
24
|
+
expect(await isPortFree(port, '0.0.0.0')).toBe(false); // the bind may succeed on Windows — still not ours
|
|
25
|
+
} finally {
|
|
26
|
+
await new Promise((r) => holder.close(r));
|
|
27
|
+
}
|
|
28
|
+
expect(await isPortFree(port, '0.0.0.0')).toBe(true);
|
|
29
|
+
expect(await isPortFree(port, '127.0.0.1')).toBe(true);
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
test('isWildcardHost', () => {
|
|
33
|
+
for (const h of ['0.0.0.0', '::', '', undefined, null]) expect(isWildcardHost(h)).toBe(true);
|
|
34
|
+
for (const h of ['127.0.0.1', 'localhost', '::1', '192.168.1.5']) expect(isWildcardHost(h)).toBe(false);
|
|
35
|
+
});
|
|
36
|
+
});
|
|
15
37
|
|
|
16
38
|
describe('ServiceRegistry', () => {
|
|
17
39
|
beforeEach(() => {
|
|
@@ -16,7 +16,7 @@ import {
|
|
|
16
16
|
HTTP_STATUS,
|
|
17
17
|
COMPACTION_CONFIG
|
|
18
18
|
} from '../utilities/constants.js';
|
|
19
|
-
import { getModelQuirkStore, classifyParamError, STRIPPABLE_PARAMS } from './modelQuirks.js';
|
|
19
|
+
import { getModelQuirkStore, classifyParamError, classifyForcedValue, STRIPPABLE_PARAMS } from './modelQuirks.js';
|
|
20
20
|
import { getOllamaService, OLLAMA_MODEL_PREFIX } from './ollamaService.js';
|
|
21
21
|
import { getDaniService, DANI_MODEL_PREFIX } from './daniService.js';
|
|
22
22
|
import { SHARED_MEMORY_SCOPE } from './memoryService.js';
|
|
@@ -2334,9 +2334,35 @@ class AIService {
|
|
|
2334
2334
|
*/
|
|
2335
2335
|
_learnParamQuirk(model, error, payloadOptions) {
|
|
2336
2336
|
try {
|
|
2337
|
+
if (!payloadOptions) return false;
|
|
2338
|
+
// Forced-value quirk: the provider names the value to send ("set
|
|
2339
|
+
// reasoning_effort to 'none'" — gpt-6-astra with function tools on
|
|
2340
|
+
// chat completions, 2026-09-06). Nothing to strip; set it and retry.
|
|
2341
|
+
const forced = classifyForcedValue(error);
|
|
2342
|
+
if (forced && payloadOptions[forced.param] !== forced.value) {
|
|
2343
|
+
payloadOptions[forced.param] = forced.value;
|
|
2344
|
+
const isNew = this._quirkStore().add(model, `${forced.param}=${forced.value}`);
|
|
2345
|
+
this.logger.warn(
|
|
2346
|
+
`MODEL QUIRK ${isNew ? 'LEARNED' : 'RE-APPLIED'}: ${model} needs '${forced.param}=${forced.value}' — set and retrying once. ` +
|
|
2347
|
+
`Future requests send it (config/model-quirks.json); declare parameters.fixed.${forced.param} on the catalog entry to avoid the first failure.`,
|
|
2348
|
+
{ model, param: forced.param, value: forced.value }
|
|
2349
|
+
);
|
|
2350
|
+
return true;
|
|
2351
|
+
}
|
|
2337
2352
|
const param = classifyParamError(error);
|
|
2338
|
-
if (!param
|
|
2339
|
-
|
|
2353
|
+
if (!param) return false;
|
|
2354
|
+
// A forced value WE added is now rejected (the catalog re-routed the
|
|
2355
|
+
// model, e.g. gpt-6-astra to the Responses API, where reasoning_effort
|
|
2356
|
+
// is refused): retire the quirk, drop the key, retry once.
|
|
2357
|
+
const forcedEntry = this._quirkStore().forcedEntry(model, param);
|
|
2358
|
+
if (forcedEntry && param in payloadOptions) {
|
|
2359
|
+
delete payloadOptions[param];
|
|
2360
|
+
this._quirkStore().remove(model, forcedEntry);
|
|
2361
|
+
this.logger.warn(`MODEL QUIRK RETIRED: ${model} now rejects '${param}' — forgot '${forcedEntry}', stripped and retrying once.`, { model, param });
|
|
2362
|
+
return true;
|
|
2363
|
+
}
|
|
2364
|
+
if (!STRIPPABLE_PARAMS.includes(param)) return false;
|
|
2365
|
+
if (!(param in payloadOptions)) return false;
|
|
2340
2366
|
delete payloadOptions[param];
|
|
2341
2367
|
const isNew = this._quirkStore().add(model, param);
|
|
2342
2368
|
this.logger.warn(
|
|
@@ -70,6 +70,36 @@ function canonicalize(param) {
|
|
|
70
70
|
return PARAM_ALIASES[p] ?? p;
|
|
71
71
|
}
|
|
72
72
|
|
|
73
|
+
/** Payload keys a provider may ask us to SET to a specific value (forced quirks). */
|
|
74
|
+
export const FORCEABLE_PARAMS = Object.freeze(['reasoning_effort']);
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* A rejection that names the value to send instead of stripping a key —
|
|
78
|
+
* live-caught (2026-09-06, gpt-6-astra via the Azure gateway):
|
|
79
|
+
* 400 "Function tools with reasoning_effort are not supported for gpt-6-astra
|
|
80
|
+
* in /v1/chat/completions. To use function tools, use /v1/responses or
|
|
81
|
+
* set reasoning_effort to 'none'."
|
|
82
|
+
* Azure applies its own non-'none' reasoning default, so there is nothing to
|
|
83
|
+
* strip; the fix is to SEND the value the provider names. Returns
|
|
84
|
+
* { param, value } (canonical param) or null.
|
|
85
|
+
*/
|
|
86
|
+
export function classifyForcedValue(error) {
|
|
87
|
+
if (!error) return null;
|
|
88
|
+
const msg = String(error.message ?? error);
|
|
89
|
+
if (!/not supported|unsupported/i.test(msg)) return null;
|
|
90
|
+
const m = /\bset\s+["']?([\w.]+)["']?\s+to\s+["']([\w.-]+)["']/i.exec(msg) || /\bset\s+["']?([\w.]+)["']?\s+to\s+([\w.-]+)\b/i.exec(msg);
|
|
91
|
+
if (!m) return null;
|
|
92
|
+
const param = canonicalize(m[1]);
|
|
93
|
+
return FORCEABLE_PARAMS.includes(param) ? { param, value: m[2] } : null;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** Quirk entry encoding: a bare key = strip it; `key=value` = force that value. */
|
|
97
|
+
export function parseQuirk(entry) {
|
|
98
|
+
const s = String(entry);
|
|
99
|
+
const i = s.indexOf('=');
|
|
100
|
+
return i < 0 ? { param: s, value: undefined } : { param: s.slice(0, i), value: s.slice(i + 1) };
|
|
101
|
+
}
|
|
102
|
+
|
|
73
103
|
/**
|
|
74
104
|
* Per-model quirk memory. `{ models: { "<model>": ["temperature", …] } }`
|
|
75
105
|
* persisted atomically to <dataDir>/config/model-quirks.json.
|
|
@@ -117,11 +147,43 @@ export class ModelQuirkStore {
|
|
|
117
147
|
const set = this._quirks.get(model);
|
|
118
148
|
if (set.has(param)) return false;
|
|
119
149
|
set.add(param);
|
|
120
|
-
this.
|
|
121
|
-
this.logger?.warn?.(`[quirks] persist failed (quirk still active this session): ${err.message}`));
|
|
150
|
+
this._schedulePersist('quirk still active this session');
|
|
122
151
|
return true;
|
|
123
152
|
}
|
|
124
153
|
|
|
154
|
+
/**
|
|
155
|
+
* Persists are chained, never concurrent: two writes racing through the
|
|
156
|
+
* same temp file + rename could land the OLDER snapshot last (add, add,
|
|
157
|
+
* remove → reload showed nothing).
|
|
158
|
+
*/
|
|
159
|
+
_schedulePersist(stillActiveNote) {
|
|
160
|
+
this._persistPromise = (this._persistPromise || Promise.resolve())
|
|
161
|
+
.then(() => this._persist())
|
|
162
|
+
.catch((err) => this.logger?.warn?.(`[quirks] persist failed (${stillActiveNote}): ${err.message}`));
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* Forget a learned quirk (e.g. a forced value the provider later rejects
|
|
167
|
+
* once the catalog routes the model elsewhere). Returns true when removed.
|
|
168
|
+
*/
|
|
169
|
+
remove(model, entry) {
|
|
170
|
+
const set = this._quirks.get(model);
|
|
171
|
+
if (!set || !set.has(entry)) return false;
|
|
172
|
+
set.delete(entry);
|
|
173
|
+
if (!set.size) this._quirks.delete(model);
|
|
174
|
+
this._schedulePersist('removal still active this session');
|
|
175
|
+
return true;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/** The forced entry (`param=value`) this model carries for `param`, or null. */
|
|
179
|
+
forcedEntry(model, param) {
|
|
180
|
+
for (const entry of this.get(model)) {
|
|
181
|
+
const q = parseQuirk(entry);
|
|
182
|
+
if (q.param === param && q.value !== undefined) return entry;
|
|
183
|
+
}
|
|
184
|
+
return null;
|
|
185
|
+
}
|
|
186
|
+
|
|
125
187
|
/** Await any in-flight persist (graceful shutdown / tests). */
|
|
126
188
|
async flush() {
|
|
127
189
|
await this._persistPromise;
|
|
@@ -138,19 +200,23 @@ export class ModelQuirkStore {
|
|
|
138
200
|
}
|
|
139
201
|
|
|
140
202
|
/**
|
|
141
|
-
*
|
|
142
|
-
* (payloads are request-local)
|
|
203
|
+
* Apply this model's quirks to a payload options object IN PLACE
|
|
204
|
+
* (payloads are request-local): bare entries are removed, `key=value`
|
|
205
|
+
* entries are set. Returns the list of touched keys.
|
|
143
206
|
*/
|
|
144
207
|
sanitize(model, payloadOptions) {
|
|
145
|
-
const
|
|
146
|
-
if (!payloadOptions || typeof payloadOptions !== 'object') return
|
|
147
|
-
for (const
|
|
148
|
-
|
|
208
|
+
const touched = [];
|
|
209
|
+
if (!payloadOptions || typeof payloadOptions !== 'object') return touched;
|
|
210
|
+
for (const entry of this.get(model)) {
|
|
211
|
+
const { param, value } = parseQuirk(entry);
|
|
212
|
+
if (value !== undefined) {
|
|
213
|
+
if (payloadOptions[param] !== value) { payloadOptions[param] = value; touched.push(param); }
|
|
214
|
+
} else if (param in payloadOptions) {
|
|
149
215
|
delete payloadOptions[param];
|
|
150
|
-
|
|
216
|
+
touched.push(param);
|
|
151
217
|
}
|
|
152
218
|
}
|
|
153
|
-
return
|
|
219
|
+
return touched;
|
|
154
220
|
}
|
|
155
221
|
}
|
|
156
222
|
|
|
@@ -318,7 +318,27 @@ export async function findFreePort(preferredPort, maxAttempts = 100, host = '127
|
|
|
318
318
|
* @param {string} host - Host to check (default: '127.0.0.1')
|
|
319
319
|
* @returns {Promise<boolean>}
|
|
320
320
|
*/
|
|
321
|
+
/** '0.0.0.0' / '::' / empty = bind every interface. */
|
|
322
|
+
export function isWildcardHost(host) {
|
|
323
|
+
return host === undefined || host === null || host === '' || host === '0.0.0.0' || host === '::';
|
|
324
|
+
}
|
|
325
|
+
|
|
321
326
|
export async function isPortFree(port, host = '127.0.0.1') {
|
|
327
|
+
// A wildcard bind is NOT proof the port is free on Windows: binding 0.0.0.0:P
|
|
328
|
+
// succeeds while another program holds 127.0.0.1:P, and every connection to
|
|
329
|
+
// localhost:P then reaches THAT program (measured 2026-09-06: llama-server on
|
|
330
|
+
// 127.0.0.1:8080, our 0.0.0.0:8080 bind "worked", the Electron shell polled
|
|
331
|
+
// /api/health against llama-server for 30 attempts → "Backend did not become
|
|
332
|
+
// healthy"). The shell, the UI and the relay all connect via loopback, so a
|
|
333
|
+
// wildcard port is free only if loopback is free too.
|
|
334
|
+
if (isWildcardHost(host)) {
|
|
335
|
+
const loopbackFree = await bindProbe(port, '127.0.0.1');
|
|
336
|
+
if (!loopbackFree) return false;
|
|
337
|
+
}
|
|
338
|
+
return bindProbe(port, host);
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
async function bindProbe(port, host) {
|
|
322
342
|
return new Promise((resolve) => {
|
|
323
343
|
const server = net.createServer();
|
|
324
344
|
|