@aria-framework/ai 0.3.0 → 0.5.0
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/health.js +219 -0
- package/index.js +9 -0
- package/package.json +3 -3
- package/views/ai/provider-card.ejs +138 -0
- package/views/ai/usage-table.ejs +58 -0
package/health.js
ADDED
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Is a provider actually usable right now — and if not, why not.
|
|
3
|
+
*
|
|
4
|
+
* NOTHING IN ANY APP DOES THIS TODAY. A provider is configured and then discovered to be broken by
|
|
5
|
+
* a user pressing a button, which is the worst possible detector: it is slow, it is intermittent,
|
|
6
|
+
* and the person who finds out is the person least able to fix it.
|
|
7
|
+
*
|
|
8
|
+
* ── "UP" AND "LOADED" ARE DIFFERENT FAILURES ────────────────────────────────────────────────────
|
|
9
|
+
* The distinction most tools collapse into one green dot, and the one that matters most for a local
|
|
10
|
+
* model server. LM Studio will happily answer `/models` while the model itself has been evicted —
|
|
11
|
+
* the server is up, and the next completion pays a multi-second reload before it does anything.
|
|
12
|
+
* Reporting that as "healthy" hides the only fact worth knowing. So `reachable` and `modelPresent`
|
|
13
|
+
* are separate, and the UI shows them separately.
|
|
14
|
+
*
|
|
15
|
+
* `modelPresent` is NULL, not false, where residency has no meaning — a hosted API does not load
|
|
16
|
+
* models on demand, and flagging one as "not resident" would be a false alarm on a working
|
|
17
|
+
* provider. Null means "not applicable", false means "we asked and it is not there".
|
|
18
|
+
*
|
|
19
|
+
* ── THE CIRCUIT BREAKER IS WHY THIS IS NOT JUST A PING ──────────────────────────────────────────
|
|
20
|
+
* Without one, every call to a dead provider pays the full timeout before failing over. Annoying at
|
|
21
|
+
* helpdesk volume; fatal at log-triage volume, where it adds the timeout to every request in the
|
|
22
|
+
* queue. After N consecutive failures a provider is marked down and calls skip it entirely until a
|
|
23
|
+
* cooldown expires; one success clears it.
|
|
24
|
+
*
|
|
25
|
+
* STATE IS IN MEMORY, deliberately. A restart re-probes, which is correct — knowledge of what was
|
|
26
|
+
* down five minutes before a restart is not knowledge worth acting on, and persisting it would mean
|
|
27
|
+
* booting into a breaker that trips on stale evidence. `snapshot()` exposes it for an app that
|
|
28
|
+
* wants to render "last good 3h ago" across a restart; nothing here requires that.
|
|
29
|
+
*/
|
|
30
|
+
|
|
31
|
+
'use strict';
|
|
32
|
+
|
|
33
|
+
const DEFAULT_TIMEOUT_MS = 8000;
|
|
34
|
+
|
|
35
|
+
/** Kinds that load models on demand, where residency is a real question. */
|
|
36
|
+
const RESIDENT_KINDS = new Set(['lmstudio', 'openai-compatible']);
|
|
37
|
+
|
|
38
|
+
/** Strip a trailing slash so `${base}/models` never becomes `//models`. */
|
|
39
|
+
const apiRoot = (baseUrl) => String(baseUrl || '').replace(/\/+$/, '');
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* The default probe: ask the endpoint what models it has.
|
|
43
|
+
*
|
|
44
|
+
* Distinguishes three outcomes that `listModels()` collapses into an empty array — it returns []
|
|
45
|
+
* for a dead host and [] for a live host with nothing loaded, which is exactly the difference this
|
|
46
|
+
* module exists to report.
|
|
47
|
+
*/
|
|
48
|
+
async function defaultProbe(cfg, { fetchImpl = fetch, timeoutMs } = {}) {
|
|
49
|
+
const url = `${apiRoot(cfg.baseUrl)}/models`;
|
|
50
|
+
const started = Date.now();
|
|
51
|
+
let res;
|
|
52
|
+
try {
|
|
53
|
+
res = await fetchImpl(url, {
|
|
54
|
+
headers: cfg.apiKey ? { Authorization: `Bearer ${cfg.apiKey}` } : {},
|
|
55
|
+
signal: AbortSignal.timeout(timeoutMs || cfg.timeoutMs || DEFAULT_TIMEOUT_MS)
|
|
56
|
+
});
|
|
57
|
+
} catch (e) {
|
|
58
|
+
// No answer at all: refused, DNS, TLS, or the timeout fired.
|
|
59
|
+
const timedOut = e && (e.name === 'TimeoutError' || e.name === 'AbortError');
|
|
60
|
+
// `e.message` is a bare "fetch failed" for every connection-level problem — measured against a
|
|
61
|
+
// closed port. Useless to an operator: refused, DNS and TLS all read identically. The reason
|
|
62
|
+
// lives in `e.cause.code`, so lead with that and keep the message as context.
|
|
63
|
+
const cause = (e && e.cause) || {};
|
|
64
|
+
const detail = cause.code || (e && e.message) || 'unreachable';
|
|
65
|
+
return {
|
|
66
|
+
reachable: false, models: [], ms: Date.now() - started,
|
|
67
|
+
error: timedOut
|
|
68
|
+
? `no response within ${timeoutMs || cfg.timeoutMs || DEFAULT_TIMEOUT_MS}ms`
|
|
69
|
+
: (cause.code ? `${cause.code}${cause.message ? ' — ' + cause.message : ''}` : detail)
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
const ms = Date.now() - started;
|
|
73
|
+
if (!res.ok) {
|
|
74
|
+
// It ANSWERED, so the host is reachable — the failure is auth, quota or a bad path, and saying
|
|
75
|
+
// "unreachable" would send an operator to check the network instead of the key.
|
|
76
|
+
return { reachable: true, models: [], ms, error: `HTTP ${res.status}` };
|
|
77
|
+
}
|
|
78
|
+
let payload = null;
|
|
79
|
+
try { payload = await res.json(); } catch (_) { payload = null; }
|
|
80
|
+
const models = Array.isArray(payload && payload.data)
|
|
81
|
+
? payload.data.map((m) => (m && (m.id || m.name)) || '').filter(Boolean)
|
|
82
|
+
: [];
|
|
83
|
+
return { reachable: true, models, ms, error: null };
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* @param {object} opts
|
|
88
|
+
* probe async (cfg) => {reachable, models, ms, error}. Default: GET {baseUrl}/models
|
|
89
|
+
* now () => number, epoch ms. Injected so tests need no timers.
|
|
90
|
+
* cooldownMs how long a provider stays skipped after tripping. Default 60_000.
|
|
91
|
+
* failureThreshold consecutive failures before it trips. Default 3.
|
|
92
|
+
*/
|
|
93
|
+
function createHealthChecker(opts = {}) {
|
|
94
|
+
const probe = typeof opts.probe === 'function' ? opts.probe : defaultProbe;
|
|
95
|
+
const now = typeof opts.now === 'function' ? opts.now : () => Date.now();
|
|
96
|
+
const cooldownMs = Number(opts.cooldownMs) || 60000;
|
|
97
|
+
const threshold = Math.max(1, Number(opts.failureThreshold) || 3);
|
|
98
|
+
|
|
99
|
+
/** id -> { failures, downUntil, lastGoodAt, lastError, lastMs, lastCheckedAt, modelPresent } */
|
|
100
|
+
const state = new Map();
|
|
101
|
+
const entry = (id) => {
|
|
102
|
+
if (!state.has(id)) {
|
|
103
|
+
state.set(id, {
|
|
104
|
+
failures: 0, downUntil: 0, lastGoodAt: null,
|
|
105
|
+
lastError: null, lastMs: null, lastCheckedAt: null, modelPresent: null
|
|
106
|
+
});
|
|
107
|
+
}
|
|
108
|
+
return state.get(id);
|
|
109
|
+
};
|
|
110
|
+
|
|
111
|
+
/** Fold one outcome into the breaker. Exposed because a real CALL is better evidence than a probe. */
|
|
112
|
+
function report(id, ok, info = {}) {
|
|
113
|
+
const e = entry(id);
|
|
114
|
+
e.lastCheckedAt = now();
|
|
115
|
+
if (info.ms != null) e.lastMs = info.ms;
|
|
116
|
+
if (ok) {
|
|
117
|
+
e.failures = 0;
|
|
118
|
+
e.downUntil = 0;
|
|
119
|
+
e.lastGoodAt = now();
|
|
120
|
+
e.lastError = null;
|
|
121
|
+
} else {
|
|
122
|
+
e.failures += 1;
|
|
123
|
+
e.lastError = info.error || 'failed';
|
|
124
|
+
// Trip on the Nth failure and re-arm the cooldown on every failure after it, so a provider
|
|
125
|
+
// that keeps failing its probe is not retried the instant the first cooldown lapses.
|
|
126
|
+
if (e.failures >= threshold) e.downUntil = now() + cooldownMs;
|
|
127
|
+
}
|
|
128
|
+
return e;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
return {
|
|
132
|
+
/**
|
|
133
|
+
* Is this provider currently being skipped?
|
|
134
|
+
*
|
|
135
|
+
* The question a router asks before spending a timeout. Note it does NOT probe — a breaker
|
|
136
|
+
* that probes on every check is the timeout cost it was built to avoid.
|
|
137
|
+
*/
|
|
138
|
+
isDown(id) {
|
|
139
|
+
const e = state.get(id);
|
|
140
|
+
return !!(e && e.downUntil && now() < e.downUntil);
|
|
141
|
+
},
|
|
142
|
+
|
|
143
|
+
/** Milliseconds until a downed provider is eligible again; 0 when it is not down. */
|
|
144
|
+
cooldownRemaining(id) {
|
|
145
|
+
const e = state.get(id);
|
|
146
|
+
if (!e || !e.downUntil) return 0;
|
|
147
|
+
return Math.max(0, e.downUntil - now());
|
|
148
|
+
},
|
|
149
|
+
|
|
150
|
+
report,
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* Probe one provider and fold the result into the breaker.
|
|
154
|
+
*
|
|
155
|
+
* @param {object} cfg a resolved provider config (see providerStore.resolve)
|
|
156
|
+
* @returns {{id, ok, reachable, modelPresent, models, ms, error, checkedAt}}
|
|
157
|
+
*/
|
|
158
|
+
async check(cfg) {
|
|
159
|
+
const id = cfg.id || cfg.provider || 'unknown';
|
|
160
|
+
if (cfg.enabled === false) {
|
|
161
|
+
return {
|
|
162
|
+
id, ok: false, reachable: null, modelPresent: null, models: [], ms: 0,
|
|
163
|
+
error: 'disabled', checkedAt: now()
|
|
164
|
+
};
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
const r = await probe(cfg);
|
|
168
|
+
// Residency only means something where models are loaded on demand.
|
|
169
|
+
let modelPresent = null;
|
|
170
|
+
if (RESIDENT_KINDS.has(cfg.provider) && r.reachable && !r.error) {
|
|
171
|
+
modelPresent = cfg.model ? r.models.includes(cfg.model) : null;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// REACHABLE BUT NOT LOADED IS NOT A FAILURE. The call will still succeed; it will just be
|
|
175
|
+
// slow while the server loads the model. Tripping the breaker here would fail over away from
|
|
176
|
+
// a provider that works, which is worse than the latency it was avoiding.
|
|
177
|
+
const ok = r.reachable && !r.error;
|
|
178
|
+
const e = report(id, ok, r);
|
|
179
|
+
e.modelPresent = modelPresent;
|
|
180
|
+
|
|
181
|
+
return {
|
|
182
|
+
id, ok, reachable: r.reachable, modelPresent, models: r.models,
|
|
183
|
+
ms: r.ms, error: r.error, checkedAt: e.lastCheckedAt
|
|
184
|
+
};
|
|
185
|
+
},
|
|
186
|
+
|
|
187
|
+
/** Everything the UI needs for one provider, without probing. */
|
|
188
|
+
status(id) {
|
|
189
|
+
const e = state.get(id);
|
|
190
|
+
if (!e) return { id, status: 'unknown', failures: 0 };
|
|
191
|
+
const down = !!(e.downUntil && now() < e.downUntil);
|
|
192
|
+
return {
|
|
193
|
+
id,
|
|
194
|
+
status: down ? 'down' : (e.lastGoodAt ? 'up' : 'unknown'),
|
|
195
|
+
failures: e.failures,
|
|
196
|
+
lastGoodAt: e.lastGoodAt,
|
|
197
|
+
lastError: e.lastError,
|
|
198
|
+
lastMs: e.lastMs,
|
|
199
|
+
lastCheckedAt: e.lastCheckedAt,
|
|
200
|
+
modelPresent: e.modelPresent,
|
|
201
|
+
cooldownRemainingMs: down ? e.downUntil - now() : 0
|
|
202
|
+
};
|
|
203
|
+
},
|
|
204
|
+
|
|
205
|
+
/** All known state, for persisting across a restart if an app wants to. */
|
|
206
|
+
snapshot() {
|
|
207
|
+
const out = {};
|
|
208
|
+
for (const id of state.keys()) out[id] = this.status(id);
|
|
209
|
+
return out;
|
|
210
|
+
},
|
|
211
|
+
|
|
212
|
+
/** Forget everything — used by tests and by "recheck now" in a UI. */
|
|
213
|
+
reset(id) {
|
|
214
|
+
if (id) state.delete(id); else state.clear();
|
|
215
|
+
}
|
|
216
|
+
};
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
module.exports = { createHealthChecker, defaultProbe, RESIDENT_KINDS };
|
package/index.js
CHANGED
|
@@ -175,6 +175,15 @@ module.exports = {
|
|
|
175
175
|
// install a database package to require this one.
|
|
176
176
|
get createUsageStore() { return require('./usageStore').createUsageStore; },
|
|
177
177
|
get createProviderStore() { return require('./providerStore').createProviderStore; },
|
|
178
|
+
// No database behind health, so it loads eagerly like the rest of the seam.
|
|
179
|
+
...require('./health'),
|
|
180
|
+
/**
|
|
181
|
+
* Where this package's EJS partials live, for the consumer's view-roots list.
|
|
182
|
+
*
|
|
183
|
+
* Same contract as backup/server/notify/uploads: the package knows its own layout, the app
|
|
184
|
+
* puts its own views FIRST so a local file of the same name wins.
|
|
185
|
+
*/
|
|
186
|
+
viewsDir: require('path').join(__dirname, 'views'),
|
|
178
187
|
get providerSchemaFor() { return require('./providerStore').schemaFor; },
|
|
179
188
|
get usageSchemaFor() { return require('./usageStore').schemaFor; },
|
|
180
189
|
createAiClient,
|
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.5.0",
|
|
5
5
|
"license": "UNLICENSED",
|
|
6
6
|
"private": false,
|
|
7
7
|
"publishConfig": {
|
|
@@ -16,7 +16,7 @@
|
|
|
16
16
|
"generate.js",
|
|
17
17
|
"providers/openai-compatible.js",
|
|
18
18
|
"providers/anthropic.js",
|
|
19
|
-
"browser/ai-polish.js", "usageStore.js", "providerStore.js"
|
|
19
|
+
"browser/ai-polish.js", "usageStore.js", "providerStore.js", "health.js", "views/"
|
|
20
20
|
],
|
|
21
21
|
"peerDependencies": {
|
|
22
22
|
"@aria-framework/db-worker": ">=0.7.0"
|
|
@@ -25,6 +25,6 @@
|
|
|
25
25
|
"@aria-framework/db-worker": { "optional": true }
|
|
26
26
|
},
|
|
27
27
|
"scripts": {
|
|
28
|
-
"test": "node test/smoke.js && node test/usageStore.js && node test/providerStore.js"
|
|
28
|
+
"test": "node test/smoke.js && node test/usageStore.js && node test/providerStore.js && node test/health.js && node test/views.js"
|
|
29
29
|
}
|
|
30
30
|
}
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
<%#
|
|
2
|
+
One provider: what it is, whether it works, and what it has cost.
|
|
3
|
+
|
|
4
|
+
LOCALS
|
|
5
|
+
p a row from createProviderStore.all()
|
|
6
|
+
health a status object from createHealthChecker.status(p.id) — may be undefined
|
|
7
|
+
usage { calls, total_tokens } for the window, or undefined
|
|
8
|
+
canEdit boolean — hides controls rather than disabling them, because a greyed-out
|
|
9
|
+
button still advertises a feature and invites a support ticket
|
|
10
|
+
routes [{ id, position }] this provider appears in, for the shared-fallback warning
|
|
11
|
+
|
|
12
|
+
WHY "reachable" AND "model loaded" ARE SHOWN SEPARATELY: a local server answers /models
|
|
13
|
+
perfectly while the model itself has been evicted, and the next call then pays a
|
|
14
|
+
multi-second reload. One green dot would hide the only fact worth knowing.
|
|
15
|
+
-%>
|
|
16
|
+
<%
|
|
17
|
+
const h = typeof health !== 'undefined' && health ? health : { status: 'unknown', failures: 0 };
|
|
18
|
+
const local = p.kind === 'lmstudio' || p.kind === 'openai-compatible';
|
|
19
|
+
const dot = !p.enabled ? 'secondary'
|
|
20
|
+
: h.status === 'down' ? 'danger'
|
|
21
|
+
: h.status === 'up' ? (h.modelPresent === false ? 'warning' : 'success')
|
|
22
|
+
: 'secondary';
|
|
23
|
+
const ago = (ts) => {
|
|
24
|
+
if (!ts) return null;
|
|
25
|
+
const s = Math.max(0, Math.round((Date.now() - ts) / 1000));
|
|
26
|
+
if (s < 60) return s + 's ago';
|
|
27
|
+
if (s < 3600) return Math.round(s / 60) + 'm ago';
|
|
28
|
+
if (s < 86400) return Math.round(s / 3600) + 'h ago';
|
|
29
|
+
return Math.round(s / 86400) + 'd ago';
|
|
30
|
+
};
|
|
31
|
+
const shared = (typeof routes !== 'undefined' && routes) ? routes : [];
|
|
32
|
+
-%>
|
|
33
|
+
<article class="card mb-3">
|
|
34
|
+
<div class="card-body d-flex flex-wrap gap-3 justify-content-between align-items-start">
|
|
35
|
+
<div class="flex-grow-1" style="min-width:18rem">
|
|
36
|
+
|
|
37
|
+
<div class="d-flex align-items-center gap-2 mb-1">
|
|
38
|
+
<span class="badge rounded-pill bg-<%= dot %>" style="width:.6rem;height:.6rem;padding:0"
|
|
39
|
+
aria-hidden="true"></span>
|
|
40
|
+
<strong class="fs-6"><%= p.id %></strong>
|
|
41
|
+
<span class="badge text-bg-light border"><%= p.kind %></span>
|
|
42
|
+
<% if (local) { %><span class="badge text-bg-light border">local</span><% } %>
|
|
43
|
+
<% if (!p.enabled) { %><span class="badge text-bg-secondary">disabled</span><% } %>
|
|
44
|
+
<% if (h.status === 'down') { %>
|
|
45
|
+
<span class="badge text-bg-danger">
|
|
46
|
+
down<%= h.cooldownRemainingMs ? ' · retrying in ' + Math.ceil(h.cooldownRemainingMs / 1000) + 's' : '' %>
|
|
47
|
+
</span>
|
|
48
|
+
<% } else if (h.modelPresent === false) { %>
|
|
49
|
+
<span class="badge text-bg-warning">model not loaded</span>
|
|
50
|
+
<% } %>
|
|
51
|
+
</div>
|
|
52
|
+
|
|
53
|
+
<div class="text-body-secondary small font-monospace mb-2">
|
|
54
|
+
<%= p.model || '(no chat model)' %><% if (p.embedding_model) { %> · <%= p.embedding_model %><% } %>
|
|
55
|
+
<% if (p.base_url) { %> · <%= p.base_url %><% } %>
|
|
56
|
+
</div>
|
|
57
|
+
|
|
58
|
+
<div class="d-flex flex-wrap gap-4 small">
|
|
59
|
+
<% if (p.context_tokens) { %>
|
|
60
|
+
<div><div class="text-body-secondary text-uppercase" style="font-size:.68rem;letter-spacing:.06em">Context</div>
|
|
61
|
+
<span class="font-monospace"><%= Number(p.context_tokens).toLocaleString() %></span></div>
|
|
62
|
+
<% } %>
|
|
63
|
+
<% if (h.lastMs != null) { %>
|
|
64
|
+
<div><div class="text-body-secondary text-uppercase" style="font-size:.68rem;letter-spacing:.06em">Latency</div>
|
|
65
|
+
<span class="font-monospace"><%= h.lastMs %> ms</span></div>
|
|
66
|
+
<% } %>
|
|
67
|
+
<% if (typeof usage !== 'undefined' && usage) { %>
|
|
68
|
+
<div><div class="text-body-secondary text-uppercase" style="font-size:.68rem;letter-spacing:.06em">Calls</div>
|
|
69
|
+
<span class="font-monospace"><%= Number(usage.calls || 0).toLocaleString() %></span></div>
|
|
70
|
+
<div><div class="text-body-secondary text-uppercase" style="font-size:.68rem;letter-spacing:.06em">Tokens</div>
|
|
71
|
+
<span class="font-monospace"><%= Number(usage.total_tokens || 0).toLocaleString() %></span></div>
|
|
72
|
+
<% } %>
|
|
73
|
+
<div>
|
|
74
|
+
<%# LAST GOOD, not just a dot. "last good 3h ago" tells a story a green light cannot. %>
|
|
75
|
+
<div class="text-body-secondary text-uppercase" style="font-size:.68rem;letter-spacing:.06em">Last good</div>
|
|
76
|
+
<span class="font-monospace"><%= ago(h.lastGoodAt) || 'never' %></span>
|
|
77
|
+
</div>
|
|
78
|
+
</div>
|
|
79
|
+
|
|
80
|
+
<% if (p.daily_token_cap) { %>
|
|
81
|
+
<%
|
|
82
|
+
const spent = (typeof usage !== 'undefined' && usage) ? Number(usage.total_tokens || 0) : 0;
|
|
83
|
+
const pct = Math.min(100, Math.round((spent / Number(p.daily_token_cap)) * 100));
|
|
84
|
+
-%>
|
|
85
|
+
<div class="mt-3" style="max-width:26rem">
|
|
86
|
+
<div class="d-flex justify-content-between small text-body-secondary">
|
|
87
|
+
<span>Daily token cap</span>
|
|
88
|
+
<span class="font-monospace"><%= spent.toLocaleString() %> / <%= Number(p.daily_token_cap).toLocaleString() %></span>
|
|
89
|
+
</div>
|
|
90
|
+
<div class="progress" style="height:.4rem" role="progressbar" aria-valuenow="<%= pct %>"
|
|
91
|
+
aria-valuemin="0" aria-valuemax="100">
|
|
92
|
+
<div class="progress-bar bg-<%= pct >= 100 ? 'danger' : pct >= 80 ? 'warning' : 'success' %>"
|
|
93
|
+
style="width:<%= pct %>%"></div>
|
|
94
|
+
</div>
|
|
95
|
+
</div>
|
|
96
|
+
<% } %>
|
|
97
|
+
|
|
98
|
+
<% if (h.status === 'down' && h.lastError) { %>
|
|
99
|
+
<div class="alert alert-danger d-flex gap-2 mt-3 mb-0 py-2">
|
|
100
|
+
<i class="bi bi-exclamation-triangle mt-1"></i>
|
|
101
|
+
<div class="small">
|
|
102
|
+
<strong>Marked down after <%= h.failures %> consecutive failure<%= h.failures === 1 ? '' : 's' %>.</strong>
|
|
103
|
+
Last error: <code><%= h.lastError %></code>. Calls skip this provider entirely rather than
|
|
104
|
+
paying its timeout, and it returns to service on the first success.
|
|
105
|
+
</div>
|
|
106
|
+
</div>
|
|
107
|
+
<% } else if (h.modelPresent === false) { %>
|
|
108
|
+
<div class="alert alert-warning d-flex gap-2 mt-3 mb-0 py-2">
|
|
109
|
+
<i class="bi bi-hourglass-split mt-1"></i>
|
|
110
|
+
<div class="small">
|
|
111
|
+
<strong>Reachable, but <code><%= p.model %></code> is not loaded.</strong>
|
|
112
|
+
The next call pays a reload before it does anything. This is not a failure and nothing
|
|
113
|
+
fails over — but if it keeps happening, turn off automatic model unloading on the server.
|
|
114
|
+
</div>
|
|
115
|
+
</div>
|
|
116
|
+
<% } %>
|
|
117
|
+
|
|
118
|
+
<% if (shared.length > 1) { %>
|
|
119
|
+
<div class="alert alert-warning d-flex gap-2 mt-3 mb-0 py-2">
|
|
120
|
+
<i class="bi bi-diagram-3 mt-1"></i>
|
|
121
|
+
<div class="small">
|
|
122
|
+
<strong>Serves <%= shared.length %> routes.</strong>
|
|
123
|
+
<%= shared.map((r) => r.id + ' (' + (r.position === 0 ? 'primary' : 'fallback') + ')').join(', ') %>.
|
|
124
|
+
If it goes down, one route fails over <em>and</em> another loses its safety net at the
|
|
125
|
+
same moment.
|
|
126
|
+
</div>
|
|
127
|
+
</div>
|
|
128
|
+
<% } %>
|
|
129
|
+
</div>
|
|
130
|
+
|
|
131
|
+
<div class="d-flex flex-column gap-2 align-items-end">
|
|
132
|
+
<% if (typeof canEdit !== 'undefined' && canEdit) { %>
|
|
133
|
+
<button class="btn btn-sm btn-outline-secondary" name="test_provider" value="<%= p.id %>">Test</button>
|
|
134
|
+
<a class="btn btn-sm btn-outline-secondary" href="?edit=<%= encodeURIComponent(p.id) %>">Edit</a>
|
|
135
|
+
<% } %>
|
|
136
|
+
</div>
|
|
137
|
+
</div>
|
|
138
|
+
</article>
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
<%#
|
|
2
|
+
Per-provider usage over a window.
|
|
3
|
+
|
|
4
|
+
LOCALS
|
|
5
|
+
rows from createUsageStore.byProvider(sinceDay) — [{provider, model, calls, ...}]
|
|
6
|
+
prices optional { [model]: costPerMillionTokens } supplied BY THE APP. Prices change, and
|
|
7
|
+
a stale hardcoded price silently produces confidently wrong cost reports, so the
|
|
8
|
+
package never carries one.
|
|
9
|
+
kinds optional { [providerId]: kind } so a local endpoint can be shown as having no cost
|
|
10
|
+
rather than a cost of zero — different facts.
|
|
11
|
+
-%>
|
|
12
|
+
<%
|
|
13
|
+
const priceFor = (m) => (typeof prices !== 'undefined' && prices && prices[m] != null) ? Number(prices[m]) : null;
|
|
14
|
+
const kindOf = (id) => (typeof kinds !== 'undefined' && kinds) ? kinds[id] : undefined;
|
|
15
|
+
const isLocal = (id) => ['lmstudio', 'openai-compatible'].includes(kindOf(id));
|
|
16
|
+
const n = (v) => Number(v || 0).toLocaleString();
|
|
17
|
+
-%>
|
|
18
|
+
<div class="table-responsive">
|
|
19
|
+
<table class="table table-sm align-middle mb-0">
|
|
20
|
+
<thead>
|
|
21
|
+
<tr class="small text-body-secondary text-uppercase">
|
|
22
|
+
<th>Provider</th><th>Model</th>
|
|
23
|
+
<th class="text-end">Calls</th><th class="text-end">Prompt</th>
|
|
24
|
+
<th class="text-end">Completion</th><th class="text-end">Total</th><th class="text-end">Cost</th>
|
|
25
|
+
</tr>
|
|
26
|
+
</thead>
|
|
27
|
+
<tbody>
|
|
28
|
+
<% if (!rows || !rows.length) { %>
|
|
29
|
+
<tr><td colspan="7" class="text-body-secondary py-3">Nothing recorded in this window.</td></tr>
|
|
30
|
+
<% } %>
|
|
31
|
+
<% (rows || []).forEach(function (r) {
|
|
32
|
+
const price = priceFor(r.model);
|
|
33
|
+
const cost = price == null ? null : (Number(r.total_tokens || 0) / 1e6) * price;
|
|
34
|
+
-%>
|
|
35
|
+
<tr>
|
|
36
|
+
<td><%= r.provider %></td>
|
|
37
|
+
<td class="font-monospace small"><%= r.model %></td>
|
|
38
|
+
<td class="text-end font-monospace"><%= n(r.calls) %></td>
|
|
39
|
+
<td class="text-end font-monospace"><%= n(r.prompt_tokens) %></td>
|
|
40
|
+
<td class="text-end font-monospace"><%= n(r.completion_tokens) %></td>
|
|
41
|
+
<td class="text-end font-monospace"><%= n(r.total_tokens) %></td>
|
|
42
|
+
<td class="text-end font-monospace">
|
|
43
|
+
<%# A local GPU has NO cost, which is a different fact from a cost of zero. Showing
|
|
44
|
+
"0.00" would invite someone to compare it against a cloud row as if it were cheap. %>
|
|
45
|
+
<% if (isLocal(r.provider)) { %><span class="text-body-secondary">—</span>
|
|
46
|
+
<% } else if (cost == null) { %><span class="text-body-secondary" title="No price configured for this model">?</span>
|
|
47
|
+
<% } else { %><%= cost.toFixed(2) %><% } %>
|
|
48
|
+
</td>
|
|
49
|
+
</tr>
|
|
50
|
+
<% }); -%>
|
|
51
|
+
</tbody>
|
|
52
|
+
</table>
|
|
53
|
+
</div>
|
|
54
|
+
<p class="small text-body-secondary mt-2 mb-0">
|
|
55
|
+
Tokens are recorded for every call, including local models. Cost is derived from tokens and the
|
|
56
|
+
price table this app supplies — never stored — so a local endpoint still reports real
|
|
57
|
+
numbers, just different ones: throughput and utilisation rather than spend.
|
|
58
|
+
</p>
|