@aria-framework/ai 0.13.0 → 0.14.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/index.js +8 -2
- package/package.json +23 -6
- package/providerStore.js +19 -2
- package/providers/lmx.js +0 -0
- package/providers/lmxDiscovery.js +209 -0
- package/providers/lmxTransport.js +60 -0
- package/providers/openai-compatible.js +23 -3
package/index.js
CHANGED
|
@@ -33,13 +33,19 @@ const PROVIDERS = {
|
|
|
33
33
|
// named after somebody else.
|
|
34
34
|
lmstudio: require('./providers/openai-compatible'),
|
|
35
35
|
'openai-compatible': require('./providers/openai-compatible'),
|
|
36
|
-
anthropic: require('./providers/anthropic')
|
|
36
|
+
anthropic: require('./providers/anthropic'),
|
|
37
|
+
// 0.14.0 — a supervised fleet rather than an address. The engine URL is discovered from the
|
|
38
|
+
// supervisor's status document per call, the reasoning flag is read from the model the engine
|
|
39
|
+
// is actually running, and the whole conversation is pinned to a self-signed certificate.
|
|
40
|
+
lmx: require('./providers/lmx')
|
|
37
41
|
};
|
|
38
42
|
|
|
39
43
|
const DEFAULTS = {
|
|
40
44
|
lmstudio: { baseUrl: 'http://localhost:1234/v1', model: 'qwen3.5-9b', label: 'LM Studio' },
|
|
41
45
|
'openai-compatible': { baseUrl: 'http://localhost:11434/v1', model: '', label: 'The model server' },
|
|
42
|
-
anthropic: { baseUrl: 'https://api.anthropic.com/v1', model: 'claude-sonnet-4-5', label: 'Claude' }
|
|
46
|
+
anthropic: { baseUrl: 'https://api.anthropic.com/v1', model: 'claude-sonnet-4-5', label: 'Claude' },
|
|
47
|
+
// No baseUrl: an lmx engine's address is never configured, only discovered.
|
|
48
|
+
lmx: { baseUrl: '', model: '', label: 'lmx engine' }
|
|
43
49
|
};
|
|
44
50
|
|
|
45
51
|
const RETRY_AFTER_MS = 400;
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@aria-framework/ai",
|
|
3
3
|
"description": "Aria App Framework — AI module. A dependency-injected model seam (createAiClient) over several providers (LM Studio / OpenAI-compatible / Anthropic), with a fact-preservation guard, generic Polish and Generate writing engines, and a browser polish widget. Prompts and config stay in the consuming app.",
|
|
4
|
-
"version": "0.
|
|
4
|
+
"version": "0.14.1",
|
|
5
5
|
"license": "UNLICENSED",
|
|
6
6
|
"private": false,
|
|
7
7
|
"publishConfig": {
|
|
@@ -16,16 +16,33 @@
|
|
|
16
16
|
"generate.js",
|
|
17
17
|
"providers/openai-compatible.js",
|
|
18
18
|
"providers/anthropic.js",
|
|
19
|
-
"browser/ai-polish.js",
|
|
20
|
-
"
|
|
19
|
+
"browser/ai-polish.js",
|
|
20
|
+
"usageStore.js",
|
|
21
|
+
"providerStore.js",
|
|
22
|
+
"speedStore.js",
|
|
23
|
+
"health.js",
|
|
24
|
+
"benchmark.js",
|
|
25
|
+
"views/",
|
|
26
|
+
"providers/lmx.js",
|
|
27
|
+
"providers/lmxDiscovery.js",
|
|
28
|
+
"providers/lmxTransport.js"
|
|
21
29
|
],
|
|
22
30
|
"peerDependencies": {
|
|
23
|
-
"@aria-framework/db-worker": ">=0.7.0"
|
|
31
|
+
"@aria-framework/db-worker": ">=0.7.0",
|
|
32
|
+
"undici": ">=6"
|
|
24
33
|
},
|
|
25
34
|
"peerDependenciesMeta": {
|
|
26
|
-
"@aria-framework/db-worker": {
|
|
35
|
+
"@aria-framework/db-worker": {
|
|
36
|
+
"optional": true
|
|
37
|
+
},
|
|
38
|
+
"undici": {
|
|
39
|
+
"optional": true
|
|
40
|
+
}
|
|
27
41
|
},
|
|
28
42
|
"scripts": {
|
|
29
|
-
"test": "node test/smoke.js && node test/usageStore.js && node test/providerStore.js && node test/speedStore.js && node test/health.js && node test/listModels.js && node test/benchmark.js && node test/packaging.js && node test/views.js"
|
|
43
|
+
"test": "node test/smoke.js && node test/usageStore.js && node test/providerStore.js && node test/speedStore.js && node test/health.js && node test/listModels.js && node test/benchmark.js && node test/lmxDiscovery.js && node test/lmx.js && node test/packaging.js && node test/views.js"
|
|
44
|
+
},
|
|
45
|
+
"devDependencies": {
|
|
46
|
+
"undici": "^8.10.0"
|
|
30
47
|
}
|
|
31
48
|
}
|
package/providerStore.js
CHANGED
|
@@ -41,7 +41,18 @@ const FIELDS = [
|
|
|
41
41
|
//
|
|
42
42
|
// An app that has not added the column is unaffected: clean() skips anything undefined, so the
|
|
43
43
|
// field is only ever written by a caller that knows about it.
|
|
44
|
-
'min_tokens_per_sec'
|
|
44
|
+
'min_tokens_per_sec',
|
|
45
|
+
// WHICH ENGINE ON WHICH SUPERVISED STACK, for `kind: lmx`. Identity is the PAIR: engine names are
|
|
46
|
+
// stable but say nothing about purpose, and they collide across deployments — `analysis` exists
|
|
47
|
+
// on every stack — so a name alone would let a mis-pointed status URL send work to another
|
|
48
|
+
// machine and get plausible answers back.
|
|
49
|
+
//
|
|
50
|
+
// Deliberately NOT reusing `model`: the status document already carries `model` as a fact about
|
|
51
|
+
// the engine, and conflating the two would make a model swap look like a configuration change.
|
|
52
|
+
//
|
|
53
|
+
// An lmx row has no meaningful `base_url`. The address is discovered from the supervisor on every
|
|
54
|
+
// call, because ports move and a stored URL is the one thing the contract says not to keep.
|
|
55
|
+
'lmx_instance', 'lmx_engine'
|
|
45
56
|
];
|
|
46
57
|
|
|
47
58
|
const NUMERIC = new Set(['context_tokens', 'max_tokens', 'timeout_ms', 'daily_token_cap', 'enabled',
|
|
@@ -208,8 +219,14 @@ function schemaFor(dialect) {
|
|
|
208
219
|
-- can report a verdict rather than a number to be remembered. 0 means no expectation was set,
|
|
209
220
|
-- which is not the same as expecting it to be slow.
|
|
210
221
|
min_tokens_per_sec INTEGER NOT NULL DEFAULT 0,
|
|
222
|
+
-- WHICH ENGINE ON WHICH SUPERVISED STACK, for kind 'lmx'. Identity is the PAIR: engine names are
|
|
223
|
+
-- stable but collide across deployments, so a name alone would let a mis-pointed status URL send
|
|
224
|
+
-- work to another stack's engine of the same name. An lmx row stores no base_url at all — the
|
|
225
|
+
-- address is discovered from the supervisor on every call.
|
|
226
|
+
lmx_instance TEXT,
|
|
227
|
+
lmx_engine TEXT,
|
|
211
228
|
created_at TEXT NOT NULL DEFAULT (${t.now()})
|
|
212
229
|
`;
|
|
213
230
|
}
|
|
214
231
|
|
|
215
|
-
module.exports = { createProviderStore, schemaFor, _assertId: assertId };
|
|
232
|
+
module.exports = { createProviderStore, schemaFor, _assertId: assertId, _fields: FIELDS };
|
package/providers/lmx.js
ADDED
|
Binary file
|
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Knowing WHERE to send inference, when the answer changes underneath you.
|
|
3
|
+
*
|
|
4
|
+
* An lmx stack is a supervisor and several engines. The supervisor publishes a status document
|
|
5
|
+
* saying which engines exist and what state each is in; the engines take the work. The supervisor
|
|
6
|
+
* is deliberately NOT on the request path — a client calls engines directly, so a supervisor fault
|
|
7
|
+
* cannot break an in-flight request. This module is the only part that talks to the supervisor, and
|
|
8
|
+
* it never carries work.
|
|
9
|
+
*
|
|
10
|
+
* FOUR RULES FROM THE CONTRACT, each of which exists because ignoring it fails silently:
|
|
11
|
+
*
|
|
12
|
+
* SELECT *FOR* `healthy`, NEVER AGAINST A LIST OF BAD STATES. The supervisor may add a state
|
|
13
|
+
* later; a client testing `state !== 'failed'` would start routing to it the day it appears.
|
|
14
|
+
* Testing `state === 'healthy'` cannot.
|
|
15
|
+
*
|
|
16
|
+
* `draining` MEANS FINISH WHAT YOU SENT AND SEND NOTHING NEW. It is the trap in the whole
|
|
17
|
+
* contract: a draining engine still answers 200 on its own /health, because draining is the
|
|
18
|
+
* supervisor's concept and llama-server knows nothing about it. Probe it yourself and it looks
|
|
19
|
+
* fine. Ignore the state and every rolling restart kills whatever was in flight.
|
|
20
|
+
*
|
|
21
|
+
* A STATUS OUTAGE IS NOT AN INFERENCE OUTAGE. If the listener is unreachable, keep routing on the
|
|
22
|
+
* last good document. Halting would put the supervisor back on the request path it was designed
|
|
23
|
+
* to stay off — turning a supervisor blip into a total loss of inference. Bounded, because
|
|
24
|
+
* routing on a view from an hour ago is its own kind of wrong.
|
|
25
|
+
*
|
|
26
|
+
* THE URL COMES FROM THE DOCUMENT, NEVER FROM CONFIG. Ports move. An engine bound to 0.0.0.0 is
|
|
27
|
+
* advertised at the same host the client used to reach the listener, because that is an address
|
|
28
|
+
* known to work from where the client is standing.
|
|
29
|
+
*
|
|
30
|
+
* IDENTITY IS (instance, name). Engine names are stable but say nothing about purpose, and they
|
|
31
|
+
* collide across deployments — `analysis` exists on every stack. The document names its own
|
|
32
|
+
* instance, so pointing a status URL at a different stack is caught here rather than discovered as
|
|
33
|
+
* work quietly going to the wrong machine.
|
|
34
|
+
*
|
|
35
|
+
* NOTHING HERE THROWS INTO A REQUEST. A discovery failure is an ABSENCE OF A ROUTE, which the
|
|
36
|
+
* caller turns into "try the next endpoint", not an error that trips a breaker on an engine that
|
|
37
|
+
* is very probably fine.
|
|
38
|
+
*/
|
|
39
|
+
|
|
40
|
+
'use strict';
|
|
41
|
+
|
|
42
|
+
/** Two seconds is what the contract suggests; the document is served no-store. */
|
|
43
|
+
const POLL_MS = 2000;
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* How long a document stays usable once the listener goes quiet.
|
|
47
|
+
*
|
|
48
|
+
* Long enough that a supervisor restart is invisible, short enough that nobody is routing on a view
|
|
49
|
+
* from another era. The contract says "a few minutes is reasonable".
|
|
50
|
+
*/
|
|
51
|
+
const STALE_MS = 3 * 60 * 1000;
|
|
52
|
+
|
|
53
|
+
/** The only state that may receive new work. */
|
|
54
|
+
const HEALTHY = 'healthy';
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* @param {object} opts
|
|
58
|
+
* @param {string} opts.statusUrl e.g. https://host:9443/status
|
|
59
|
+
* @param {string} opts.instance the deployment this row believes it is talking to
|
|
60
|
+
* @param {string} opts.token bearer for the status listener
|
|
61
|
+
* @param {string} [opts.ca] PEM of the certificate to pin
|
|
62
|
+
* @param {number} [opts.staleMs]
|
|
63
|
+
* @param {number} [opts.pollMs]
|
|
64
|
+
* @param {object} [opts.logger]
|
|
65
|
+
* @param {function} [opts.fetchImpl] injectable for tests; defaults to global fetch
|
|
66
|
+
* @param {function} [opts.now] injectable clock
|
|
67
|
+
*/
|
|
68
|
+
function createLmxDiscovery(opts = {}) {
|
|
69
|
+
const {
|
|
70
|
+
statusUrl,
|
|
71
|
+
instance,
|
|
72
|
+
token,
|
|
73
|
+
ca = null,
|
|
74
|
+
staleMs = STALE_MS,
|
|
75
|
+
pollMs = POLL_MS,
|
|
76
|
+
logger = console,
|
|
77
|
+
fetchImpl,
|
|
78
|
+
now = () => Date.now()
|
|
79
|
+
} = opts;
|
|
80
|
+
|
|
81
|
+
if (!statusUrl) throw new Error('createLmxDiscovery: statusUrl is required');
|
|
82
|
+
if (!instance) throw new Error('createLmxDiscovery: instance is required — identity is (instance, name)');
|
|
83
|
+
|
|
84
|
+
let doc = null; // the last document that parsed and matched our instance
|
|
85
|
+
let docAt = 0;
|
|
86
|
+
let lastError = null;
|
|
87
|
+
let timer = null;
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* The pinned transport — see providers/lmxTransport.js for why this is not NODE_EXTRA_CA_CERTS
|
|
91
|
+
* and never `rejectUnauthorized: false`. Built lazily and cached there, so a consumer that never
|
|
92
|
+
* configures lmx does not load undici at all.
|
|
93
|
+
*/
|
|
94
|
+
const transport = () => (fetchImpl
|
|
95
|
+
? { fetch: fetchImpl, dispatcher: undefined }
|
|
96
|
+
: require('./lmxTransport').lmxTransport(ca));
|
|
97
|
+
|
|
98
|
+
async function fetchOnce() {
|
|
99
|
+
const t = transport();
|
|
100
|
+
const res = await t.fetch(statusUrl, {
|
|
101
|
+
headers: { Authorization: `Bearer ${token}` },
|
|
102
|
+
dispatcher: t.dispatcher
|
|
103
|
+
});
|
|
104
|
+
if (!res.ok) {
|
|
105
|
+
const err = new Error(`status listener answered ${res.status}`);
|
|
106
|
+
err.status = res.status;
|
|
107
|
+
throw err;
|
|
108
|
+
}
|
|
109
|
+
return res.json();
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Fetch once and adopt the result if it is usable.
|
|
114
|
+
*
|
|
115
|
+
* A document for the WRONG INSTANCE is rejected rather than adopted. It is not a transport
|
|
116
|
+
* failure — the listener answered perfectly — so it is reported as a configuration error, which
|
|
117
|
+
* is what it is. Adopting it would route `analysis` to another deployment's `analysis`.
|
|
118
|
+
*/
|
|
119
|
+
async function refresh() {
|
|
120
|
+
try {
|
|
121
|
+
const next = await fetchOnce();
|
|
122
|
+
if (next && next.instance && next.instance !== instance) {
|
|
123
|
+
lastError = `status listener at ${statusUrl} reports instance "${next.instance}", not `
|
|
124
|
+
+ `"${instance}" — this endpoint is pointed at a different stack, and engine names `
|
|
125
|
+
+ 'collide across stacks';
|
|
126
|
+
logger.error(`lmx: ${lastError}`);
|
|
127
|
+
return { ok: false, error: lastError };
|
|
128
|
+
}
|
|
129
|
+
doc = next;
|
|
130
|
+
docAt = now();
|
|
131
|
+
lastError = null;
|
|
132
|
+
return { ok: true, doc: next };
|
|
133
|
+
} catch (e) {
|
|
134
|
+
lastError = e.message;
|
|
135
|
+
// LOUDLY, per the contract — and with the age, because "unreachable" matters differently at
|
|
136
|
+
// four seconds and at four minutes.
|
|
137
|
+
logger.warn(`lmx: status unreachable (${e.message}); routing on a document ${ageSec()}s old`);
|
|
138
|
+
return { ok: false, error: e.message };
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
const ageMs = () => (docAt ? now() - docAt : Infinity);
|
|
143
|
+
const ageSec = () => (docAt ? Math.round(ageMs() / 1000) : 0);
|
|
144
|
+
const isStale = () => ageMs() > staleMs;
|
|
145
|
+
|
|
146
|
+
/** Every engine in the last usable document. Empty when there is nothing fresh enough to say. */
|
|
147
|
+
function engines() {
|
|
148
|
+
if (!doc || isStale()) return [];
|
|
149
|
+
return Array.isArray(doc.engines) ? doc.engines : [];
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* The engine record for `name`, whatever state it is in — for a health card, which needs to show
|
|
154
|
+
* "draining" rather than "gone".
|
|
155
|
+
*/
|
|
156
|
+
const engine = (name) => engines().find((e) => e && e.name === name) || null;
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* Where to send work for `name`, or null.
|
|
160
|
+
*
|
|
161
|
+
* Returns a REASON alongside, because the caller has to distinguish "this engine is fine and busy
|
|
162
|
+
* being replaced" from "we cannot see the stack" from "there is no such engine" — three different
|
|
163
|
+
* things that all mean "not right now" and only one of which is anybody's fault.
|
|
164
|
+
*/
|
|
165
|
+
function resolve(name) {
|
|
166
|
+
if (!doc) return { url: null, reason: 'no_document', detail: lastError };
|
|
167
|
+
if (isStale()) return { url: null, reason: 'stale', detail: `${ageSec()}s old` };
|
|
168
|
+
|
|
169
|
+
const e = engine(name);
|
|
170
|
+
if (!e) return { url: null, reason: 'unknown_engine' };
|
|
171
|
+
|
|
172
|
+
// Select FOR healthy. `draining`, `restarting`, and any state invented after this was written
|
|
173
|
+
// all fail this test, which is the property worth having.
|
|
174
|
+
if (e.state !== HEALTHY) return { url: null, reason: 'not_healthy', detail: e.state };
|
|
175
|
+
if (!e.url) return { url: null, reason: 'no_url' };
|
|
176
|
+
|
|
177
|
+
return { url: e.url, engine: e };
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
function start() {
|
|
181
|
+
if (timer) return;
|
|
182
|
+
refresh();
|
|
183
|
+
timer = setInterval(refresh, pollMs);
|
|
184
|
+
if (timer.unref) timer.unref(); // never hold a process open for a poller
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
function stop() {
|
|
188
|
+
if (timer) clearInterval(timer);
|
|
189
|
+
timer = null;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
return {
|
|
193
|
+
start, stop, refresh, resolve, engines, engine,
|
|
194
|
+
/** The pinned transport, so ENGINE calls reach the same host over the same trust. */
|
|
195
|
+
transport,
|
|
196
|
+
/** For a diagnostics panel: what we know and how old it is. */
|
|
197
|
+
status: () => ({
|
|
198
|
+
instance,
|
|
199
|
+
statusUrl,
|
|
200
|
+
overall: doc ? doc.state : null,
|
|
201
|
+
engineCount: engines().length,
|
|
202
|
+
ageSec: doc ? ageSec() : null,
|
|
203
|
+
stale: doc ? isStale() : true,
|
|
204
|
+
lastError
|
|
205
|
+
})
|
|
206
|
+
};
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
module.exports = { createLmxDiscovery, POLL_MS, STALE_MS, HEALTHY };
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The pinned HTTPS transport an lmx stack is reached over — status listener AND engines.
|
|
3
|
+
*
|
|
4
|
+
* WHY THIS IS NOT `NODE_EXTRA_CA_CERTS`. That is the first thing the contract suggests and the
|
|
5
|
+
* wrong answer here, for three reasons: it is read once before Node starts, so a certificate that
|
|
6
|
+
* lives in the database can never get into it; it is PROCESS-GLOBAL, so a second stack with its own
|
|
7
|
+
* certificate is impossible and every unrelated outbound request in the app silently gains a new
|
|
8
|
+
* trusted root; and it cannot be overridden per engine, which the configuration deliberately
|
|
9
|
+
* allows.
|
|
10
|
+
*
|
|
11
|
+
* WHY NOT `rejectUnauthorized: false`. Because that is not "ignore this one self-signed
|
|
12
|
+
* certificate", it is "accept any certificate from anyone able to answer on that address" — the
|
|
13
|
+
* whole attack pinning exists to stop. The option appears nowhere in this package and a test
|
|
14
|
+
* asserts its absence.
|
|
15
|
+
*
|
|
16
|
+
* `ca` REPLACES the trust store for this connection rather than adding to it. That is what makes it
|
|
17
|
+
* a pin: only this certificate is accepted, not this certificate plus every public CA.
|
|
18
|
+
*
|
|
19
|
+
* UNDICI'S OWN `fetch`, NOT THE GLOBAL ONE. Node's global fetch is built on a private copy of
|
|
20
|
+
* undici, and there is no supported way to hand it a dispatcher from the copy installed here — the
|
|
21
|
+
* two do not have to recognise each other's classes. Using the installed package for both halves
|
|
22
|
+
* removes the question entirely.
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
'use strict';
|
|
26
|
+
|
|
27
|
+
/** Cache by certificate text: one agent per distinct cert, not one per request. */
|
|
28
|
+
const agents = new Map();
|
|
29
|
+
|
|
30
|
+
function requireUndici() {
|
|
31
|
+
try {
|
|
32
|
+
return require('undici');
|
|
33
|
+
} catch (e) {
|
|
34
|
+
throw new Error(
|
|
35
|
+
'lmx needs the `undici` package to pin a self-signed certificate — Node\'s built-in fetch '
|
|
36
|
+
+ 'cannot be given a certificate authority. Install it: npm install undici'
|
|
37
|
+
);
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* @param {string|null} ca PEM text of the certificate to pin. Null means the system trust store,
|
|
43
|
+
* which is correct for a stack behind a normally-issued certificate.
|
|
44
|
+
* @returns {{fetch: function, dispatcher: object|undefined}}
|
|
45
|
+
*/
|
|
46
|
+
function lmxTransport(ca) {
|
|
47
|
+
if (!ca) return { fetch: globalThis.fetch, dispatcher: undefined };
|
|
48
|
+
|
|
49
|
+
const key = String(ca);
|
|
50
|
+
if (!agents.has(key)) {
|
|
51
|
+
const { Agent } = requireUndici();
|
|
52
|
+
agents.set(key, new Agent({ connect: { ca: key } }));
|
|
53
|
+
}
|
|
54
|
+
return { fetch: requireUndici().fetch, dispatcher: agents.get(key) };
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** Test seam: forget the cached agents so a rotated certificate is picked up. */
|
|
58
|
+
lmxTransport._reset = () => agents.clear();
|
|
59
|
+
|
|
60
|
+
module.exports = { lmxTransport };
|
|
@@ -20,6 +20,16 @@ const { AiError, fromFetchFailure, redact } = require('../error');
|
|
|
20
20
|
* temperature?:number, schema?:object, signal?:AbortSignal}} opts
|
|
21
21
|
* @returns {Promise<{text:string, json:object|null, model:string, usage:object, ms:number}>}
|
|
22
22
|
*/
|
|
23
|
+
/**
|
|
24
|
+
* The HTTP transport for this config.
|
|
25
|
+
*
|
|
26
|
+
* Defaults to the global fetch, which is right for every provider reached over ordinary TLS. A
|
|
27
|
+
* caller that must PIN a certificate — lmx, whose stack is self-signed — passes its own
|
|
28
|
+
* { fetch, dispatcher } through cfg.transport, so engine calls travel over the same pinned trust
|
|
29
|
+
* as the status poll rather than falling back to the system trust store for the actual work.
|
|
30
|
+
*/
|
|
31
|
+
const tx = (cfg) => (cfg && cfg.transport) || { fetch: globalThis.fetch, dispatcher: undefined };
|
|
32
|
+
|
|
23
33
|
async function complete(cfg, opts) {
|
|
24
34
|
const label = cfg.label || 'The model server';
|
|
25
35
|
const url = apiRoot(cfg.baseUrl) + '/chat/completions';
|
|
@@ -31,6 +41,13 @@ async function complete(cfg, opts) {
|
|
|
31
41
|
for (const m of opts.messages || []) messages.push({ role: m.role, content: m.content });
|
|
32
42
|
|
|
33
43
|
const body = {
|
|
44
|
+
// SERVER-SPECIFIC ARGUMENTS THIS ADAPTER KNOWS NOTHING ABOUT, spread FIRST so nothing in a
|
|
45
|
+
// passthrough can override the fields below — a caller must not be able to change the model or
|
|
46
|
+
// turn on streaming this way. Its purpose is arguments that are real, documented and outside
|
|
47
|
+
// the OpenAI schema: llama.cpp's `chat_template_kwargs`, gpt-oss's `reasoning_effort`. Both are
|
|
48
|
+
// load-bearing — without the right one the model spends its whole budget reasoning and returns
|
|
49
|
+
// empty content with finish_reason "length", and no error at all.
|
|
50
|
+
...(opts.extra || {}),
|
|
34
51
|
model: cfg.model,
|
|
35
52
|
messages,
|
|
36
53
|
max_tokens: opts.maxTokens || 1024,
|
|
@@ -55,7 +72,8 @@ async function complete(cfg, opts) {
|
|
|
55
72
|
|
|
56
73
|
let res;
|
|
57
74
|
try {
|
|
58
|
-
res = await fetch(url, {
|
|
75
|
+
res = await tx(cfg).fetch(url, {
|
|
76
|
+
dispatcher: tx(cfg).dispatcher,
|
|
59
77
|
method: 'POST',
|
|
60
78
|
headers: Object.assign(
|
|
61
79
|
{ 'Content-Type': 'application/json' },
|
|
@@ -304,7 +322,8 @@ function normaliseUsage(u) {
|
|
|
304
322
|
async function listModelsResult(cfg) {
|
|
305
323
|
const url = apiRoot(cfg.baseUrl) + '/models';
|
|
306
324
|
try {
|
|
307
|
-
const res = await fetch(url, {
|
|
325
|
+
const res = await tx(cfg).fetch(url, {
|
|
326
|
+
dispatcher: tx(cfg).dispatcher,
|
|
308
327
|
headers: cfg.apiKey ? { Authorization: `Bearer ${cfg.apiKey}` } : {},
|
|
309
328
|
signal: AbortSignal.timeout(cfg.timeoutMs || 10000)
|
|
310
329
|
});
|
|
@@ -353,7 +372,8 @@ async function embed(cfg, texts) {
|
|
|
353
372
|
const timer = setTimeout(() => controller.abort(), cfg.timeoutMs || 60000);
|
|
354
373
|
let res;
|
|
355
374
|
try {
|
|
356
|
-
res = await fetch(url, {
|
|
375
|
+
res = await tx(cfg).fetch(url, {
|
|
376
|
+
dispatcher: tx(cfg).dispatcher,
|
|
357
377
|
method: 'POST', headers, signal: controller.signal,
|
|
358
378
|
body: JSON.stringify({ model: cfg.embeddingModel, input: list })
|
|
359
379
|
});
|