@worca/app 1.5.0 → 1.6.0-rc.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/README.md +7 -1
- package/docker/compose.broker.yml +79 -0
- package/docker/compose.isolation.yml +40 -0
- package/package.json +2 -1
- package/src/broker/config.mjs +200 -0
- package/src/broker/copilot.mjs +124 -0
- package/src/broker/limits.mjs +88 -0
- package/src/broker/main.mjs +129 -0
- package/src/broker/scrub.mjs +45 -0
- package/src/broker/service.mjs +558 -0
- package/src/broker/slots.mjs +180 -0
- package/src/broker/store.mjs +171 -0
- package/src/broker/tokens.mjs +116 -0
- package/src/broker/ui/page.css +54 -0
- package/src/broker/ui/page.html +25 -0
- package/src/broker/ui/page.mjs +202 -0
- package/src/broker/ui-server.mjs +217 -0
- package/src/broker/usage.mjs +108 -0
- package/src/broker/vault.mjs +37 -0
- package/src/cli/models.mjs +46 -14
- package/src/cli/render.mjs +21 -0
- package/src/cli/runs.mjs +296 -0
- package/src/cli/worca-cc.mjs +14 -2
- package/src/core/agent-pool.mjs +76 -0
- package/src/core/artifacts.mjs +40 -8
- package/src/core/ask/events.mjs +11 -0
- package/src/core/ask/html-text.mjs +113 -0
- package/src/core/ask/limits.mjs +5 -0
- package/src/core/ask/mcp-stdio.mjs +74 -20
- package/src/core/ask/prompt.mjs +25 -6
- package/src/core/ask/spawn.mjs +55 -5
- package/src/core/ask/store.mjs +1 -1
- package/src/core/ask/tools.mjs +66 -1
- package/src/core/ask/turn.mjs +52 -3
- package/src/core/ask/web-access.mjs +26 -0
- package/src/core/ask/web-deps.mjs +56 -0
- package/src/core/ask/web-fetch.mjs +271 -0
- package/src/core/ask/web-proposal.mjs +76 -0
- package/src/core/auto/classify.mjs +20 -8
- package/src/core/auto/runnable.mjs +28 -0
- package/src/core/billing.mjs +53 -0
- package/src/core/bridge/errors.mjs +77 -5
- package/src/core/bridge/openrouter.mjs +59 -0
- package/src/core/bridge/provider-ops.mjs +165 -9
- package/src/core/bridge/providers/endpoint.mjs +112 -7
- package/src/core/bridge/registry.mjs +13 -0
- package/src/core/bridge/server.mjs +16 -3
- package/src/core/bridge/telemetry.mjs +44 -12
- package/src/core/bridge/translate/common.mjs +31 -0
- package/src/core/bridge/translate/request.mjs +4 -1
- package/src/core/bridge/translate/response.mjs +3 -0
- package/src/core/bridge/translate/schema-keywords.mjs +75 -0
- package/src/core/bridge/translate/stream.mjs +50 -4
- package/src/core/bridge/upstream.mjs +102 -17
- package/src/core/broker-boot.mjs +57 -0
- package/src/core/broker-client.mjs +206 -0
- package/src/core/broker-guard.mjs +112 -0
- package/src/core/broker-routing.mjs +138 -0
- package/src/core/claude-auth.mjs +29 -0
- package/src/core/claude-runner.mjs +200 -9
- package/src/core/config.mjs +9 -12
- package/src/core/failure-policy.mjs +4 -2
- package/src/core/git-info.mjs +49 -5
- package/src/core/github-credentials.mjs +44 -1
- package/src/core/graph/script-runner.mjs +3 -1
- package/src/core/list-prices.mjs +29 -0
- package/src/core/mcp-secrets.mjs +80 -0
- package/src/core/metrics/sync.mjs +2 -1
- package/src/core/model-env.mjs +72 -0
- package/src/core/model-test.mjs +17 -5
- package/src/core/onboarding.mjs +12 -6
- package/src/core/openrouter-free.mjs +159 -0
- package/src/core/orchestrator.mjs +100 -12
- package/src/core/policy/effective.mjs +18 -1
- package/src/core/policy/local.mjs +4 -1
- package/src/core/policy/registry.mjs +12 -2
- package/src/core/preflight.mjs +116 -0
- package/src/core/recoverable-error.mjs +95 -0
- package/src/core/recovery-backoff.mjs +84 -0
- package/src/core/redact.mjs +25 -0
- package/src/core/run-context.mjs +6 -0
- package/src/core/run-harness.mjs +112 -30
- package/src/core/run-report.mjs +2 -1
- package/src/core/settings.mjs +90 -5
- package/src/core/title.mjs +7 -2
- package/src/core/web-allowlist.mjs +95 -0
- package/ui/public/app.js +244 -43
- package/ui/public/ask-model.mjs +1 -0
- package/ui/public/ask-panel.mjs +160 -23
- package/ui/public/bridge-view.mjs +175 -6
- package/ui/public/chat-settings-view.mjs +52 -1
- package/ui/public/credential-badges.mjs +63 -0
- package/ui/public/credentials-view.mjs +57 -0
- package/ui/public/index.html +33 -6
- package/ui/public/models-view.mjs +19 -1
- package/ui/public/openrouter-free-view.mjs +118 -0
- package/ui/public/stats-view.mjs +56 -0
- package/ui/public/style.css +99 -50
- package/ui/public/team-policy-view.mjs +2 -1
- package/ui/public/ws-seq.mjs +24 -0
- package/ui/server.mjs +319 -23
|
@@ -15,9 +15,50 @@ import { modelEnvRef, maskModelEnvValue, COPILOT_TERMS_VERSION, UPSTREAM_PROVIDE
|
|
|
15
15
|
import {
|
|
16
16
|
startDeviceFlow, pollDeviceFlow, githubLogin, copilotToken, invalidateCopilotToken,
|
|
17
17
|
listCopilotModels, copilotUsage, catalogEntryForCopilotModel, copilotApiFor,
|
|
18
|
+
copilotHeaders, normalizeCopilotModel,
|
|
18
19
|
} from './providers/copilot.mjs';
|
|
19
20
|
import { keyOptional } from './registry.mjs';
|
|
20
21
|
import { listEndpointModels, catalogEntryForEndpointModel, importableModel } from './providers/endpoint.mjs';
|
|
22
|
+
import { brokerEnabled, brokerInfo, cachedBrokerInfo, mintSpawnToken, revokeSpawnToken, slotBaseUrl } from '../broker-client.mjs';
|
|
23
|
+
import { routeUpstream } from '../broker-routing.mjs';
|
|
24
|
+
import { resolveBillTo } from '../billing.mjs';
|
|
25
|
+
|
|
26
|
+
// ── credential broker (docs/credential-broker.md) ────────────────────────────
|
|
27
|
+
|
|
28
|
+
/** With the broker on, provider keys and sign-ins live on its key page, never in worca. */
|
|
29
|
+
function refuseWithBroker(what) {
|
|
30
|
+
if (!brokerEnabled()) return;
|
|
31
|
+
const keyPage = cachedBrokerInfo()?.publicUrl;
|
|
32
|
+
throw Object.assign(new Error(`worca can't ${what}: with the credential broker, each person does that on the key page${keyPage ? ` (${keyPage})` : ''}`), { code: 'BROKER' });
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Run `fn(token)` with a short-lived broker token for `slot`, billed to the person behind
|
|
37
|
+
* the current request (billing.mjs), revoked afterwards. Model discovery and imports use it.
|
|
38
|
+
*/
|
|
39
|
+
async function withAuxBrokerToken(slot, fn) {
|
|
40
|
+
const info = await brokerInfo();
|
|
41
|
+
let billTo = resolveBillTo(null);
|
|
42
|
+
if (info.mode === 'multi' && (!billTo || billTo === 'local')) {
|
|
43
|
+
throw Object.assign(new Error('sign in through the identity proxy first: the provider is asked with your own key'), { code: 'BROKER' });
|
|
44
|
+
}
|
|
45
|
+
const minted = await mintSpawnToken({ billTo: billTo || 'local', slots: [slot], kind: 'aux' });
|
|
46
|
+
try { return await fn(minted.token); } finally { revokeSpawnToken(minted.spawnId); }
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** A broker refusal's own words (`worca-broker: …`), else a generic status line. */
|
|
50
|
+
async function brokerFailure(res, what) {
|
|
51
|
+
try {
|
|
52
|
+
const j = await res.json();
|
|
53
|
+
const m = j?.error?.message;
|
|
54
|
+
if (typeof m === 'string' && m) return m;
|
|
55
|
+
} catch { /* not JSON */ }
|
|
56
|
+
return `${what} answered ${res.status}`;
|
|
57
|
+
}
|
|
58
|
+
import { isOpenRouter } from './openrouter.mjs';
|
|
59
|
+
|
|
60
|
+
/** The OpenRouter API base a preset / `worca models set openrouter` points the openai provider at. */
|
|
61
|
+
export const OPENROUTER_BASE_URL = 'https://openrouter.ai/api/v1';
|
|
21
62
|
|
|
22
63
|
// ── Copilot sign-in sessions (in memory; a device code lives ~15 min) ────────
|
|
23
64
|
const sessions = new Map(); // deviceCode -> { startedAt, expiresAt, interval, lastPoll }
|
|
@@ -32,6 +73,7 @@ function sweepSessions(now = Date.now()) {
|
|
|
32
73
|
* @returns {Promise<{deviceCode, userCode, verificationUri, interval, expiresIn}>}
|
|
33
74
|
*/
|
|
34
75
|
export async function beginCopilotLogin({ fetch: f } = {}) {
|
|
76
|
+
refuseWithBroker('sign in to GitHub Copilot');
|
|
35
77
|
if (!copilotTermsAcknowledged()) {
|
|
36
78
|
throw Object.assign(new Error('acknowledge the GitHub Copilot notice before signing in'), { code: 'TERMS' });
|
|
37
79
|
}
|
|
@@ -119,7 +161,9 @@ export async function providersState({ quota = false, fetch: f } = {}) {
|
|
|
119
161
|
maxConcurrent: p.maxConcurrent,
|
|
120
162
|
};
|
|
121
163
|
};
|
|
122
|
-
|
|
164
|
+
// With the credential broker on, keys and the Copilot sign-in are per person on its key page.
|
|
165
|
+
const broker = brokerEnabled() ? { enabled: true, keyPage: cachedBrokerInfo()?.publicUrl || null, mode: cachedBrokerInfo()?.mode || null } : { enabled: false };
|
|
166
|
+
return { copilot, openai: keyed('openai'), anthropic: keyed('anthropic'), broker };
|
|
123
167
|
}
|
|
124
168
|
|
|
125
169
|
/** Patch a provider from the UI/CLI; masked key echoes are dropped ("keep"). */
|
|
@@ -127,6 +171,8 @@ export async function patchProvider(name, patch = {}) {
|
|
|
127
171
|
if (!UPSTREAM_PROVIDERS.includes(name)) throw new Error(`unknown provider ${JSON.stringify(name)}`);
|
|
128
172
|
const p = { ...patch };
|
|
129
173
|
for (const k of ['apiKey', 'githubToken']) if (typeof p[k] === 'string' && p[k].startsWith('••')) delete p[k];
|
|
174
|
+
// Credential broker: keys are per person, on the key page; worca stores none.
|
|
175
|
+
if (brokerEnabled() && ['apiKey', 'githubToken'].some((k) => typeof p[k] === 'string' && p[k].trim())) refuseWithBroker('store a provider key');
|
|
130
176
|
return updateProvider(name, p);
|
|
131
177
|
}
|
|
132
178
|
|
|
@@ -134,10 +180,24 @@ export async function patchProvider(name, patch = {}) {
|
|
|
134
180
|
|
|
135
181
|
/** Copilot's models for the import sheet, each with `inCatalog` (§8.4). */
|
|
136
182
|
export async function copilotModelsForImport({ fetch: f } = {}) {
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
183
|
+
let list;
|
|
184
|
+
if (brokerEnabled()) {
|
|
185
|
+
// The signed-in person's Copilot, through the broker (their GitHub sign-in lives there).
|
|
186
|
+
const route = routeUpstream({ provider: 'copilot' }, { slots: (await brokerInfo()).slots || [] });
|
|
187
|
+
if (route.error) throw new Error(route.error);
|
|
188
|
+
list = await withAuxBrokerToken(route.slot, async (token) => {
|
|
189
|
+
const r = await (f || globalThis.fetch)(`${slotBaseUrl(route.slot)}/models`, { headers: copilotHeaders(token) });
|
|
190
|
+
if (!r.ok) throw Object.assign(new Error(await brokerFailure(r, 'Copilot /models')), { status: r.status });
|
|
191
|
+
const j = await r.json();
|
|
192
|
+
const data = Array.isArray(j.data) ? j.data : (Array.isArray(j) ? j : []);
|
|
193
|
+
return data.map(normalizeCopilotModel).filter((m) => m && (!m.type || m.type === 'chat'));
|
|
194
|
+
});
|
|
195
|
+
} else {
|
|
196
|
+
const c = providerConfig('copilot');
|
|
197
|
+
const token = resolveProviderSecret(c.githubToken);
|
|
198
|
+
if (!token) throw Object.assign(new Error('not signed in to GitHub Copilot'), { code: 'NOT_SIGNED_IN' });
|
|
199
|
+
list = await listCopilotModels(token, { accountType: c.accountType, fetch: f });
|
|
200
|
+
}
|
|
141
201
|
const have = new Set(listGlobalModels().map((m) => m.id.toLowerCase()));
|
|
142
202
|
return list.map((m) => ({ ...m, api: copilotApiFor(m), catalogId: `copilot-${m.id}`, inCatalog: have.has(`copilot-${m.id}`.toLowerCase()) }))
|
|
143
203
|
.sort((a, b) => (Number(b.pickerEnabled) - Number(a.pickerEnabled)) || a.name.localeCompare(b.name));
|
|
@@ -204,19 +264,47 @@ function endpointBase(baseUrl) {
|
|
|
204
264
|
export async function endpointModelsForImport({ baseUrl, fetch: f } = {}) {
|
|
205
265
|
const base = endpointBase(baseUrl);
|
|
206
266
|
const p = providerConfig('openai');
|
|
207
|
-
|
|
208
|
-
const
|
|
267
|
+
let out;
|
|
268
|
+
const route = brokerEnabled() ? routeUpstream({ provider: 'openai', baseUrl: base }, { slots: (await brokerInfo()).slots || [] }) : null;
|
|
269
|
+
if (route && route.error) throw new Error(route.error);
|
|
270
|
+
if (route && route.slot) {
|
|
271
|
+
// An endpoint behind a broker slot is asked with the clicking person's key; the listing
|
|
272
|
+
// then names the REAL base URL, so the imported entries point at it.
|
|
273
|
+
out = await withAuxBrokerToken(route.slot, (token) => listEndpointModels(`${slotBaseUrl(route.slot)}${route.prefix}`, { apiKey: token, fetch: f, realBaseUrl: base }));
|
|
274
|
+
out = { ...out, baseUrl: base };
|
|
275
|
+
} else {
|
|
276
|
+
// Keyless local endpoints are reached directly, as without the broker.
|
|
277
|
+
const key = brokerEnabled() ? '' : resolveProviderSecret(p.apiKey);
|
|
278
|
+
out = await listEndpointModels(base, { apiKey: key, fetch: f });
|
|
279
|
+
}
|
|
209
280
|
const have = new Set(listGlobalModels().map((m) => m.id.toLowerCase()));
|
|
210
281
|
return {
|
|
211
282
|
...out,
|
|
212
283
|
models: out.models.map((m) => {
|
|
213
284
|
const entry = catalogEntryForEndpointModel(m, { server: out.server, baseUrl: out.baseUrl, providerBaseUrl: p.baseUrl });
|
|
214
285
|
const usable = importableModel(m);
|
|
215
|
-
|
|
286
|
+
const existing = existingEndpointEntry(m.id, out.baseUrl, p.baseUrl);
|
|
287
|
+
const catalogId = existing ? existing.id : entry.id;
|
|
288
|
+
return { ...m, catalogId, inCatalog: !!existing || have.has(entry.id.toLowerCase()), importable: usable.ok, ...(usable.ok ? {} : { blocked: usable.why }) };
|
|
216
289
|
}),
|
|
217
290
|
};
|
|
218
291
|
}
|
|
219
292
|
|
|
293
|
+
const trimUrl = (u) => String(u || '').trim().replace(/\/+$/, '').toLowerCase();
|
|
294
|
+
|
|
295
|
+
/**
|
|
296
|
+
* The catalog entry that already imports `upstreamId` from this endpoint, whatever its id says.
|
|
297
|
+
* Ids are derived (and their prefix changed: a remote endpoint's models were `local-…`), so a
|
|
298
|
+
* re-import matches on what the entry POINTS AT — the openai provider, this upstream id, and this
|
|
299
|
+
* base URL (its own, or the provider's when it carries none) — and refreshes the entry the user
|
|
300
|
+
* already has instead of adding a twin under the new id.
|
|
301
|
+
*/
|
|
302
|
+
function existingEndpointEntry(upstreamId, baseUrl, providerBaseUrl) {
|
|
303
|
+
const want = trimUrl(baseUrl);
|
|
304
|
+
return listGlobalModels().find((x) => x.upstream && x.upstream.provider === 'openai'
|
|
305
|
+
&& x.upstream.model === upstreamId && trimUrl(x.upstream.baseUrl || providerBaseUrl) === want) || null;
|
|
306
|
+
}
|
|
307
|
+
|
|
220
308
|
/**
|
|
221
309
|
* Import endpoint models into the catalog. A new id gets the full entry; an existing one keeps the
|
|
222
310
|
* label, efforts and pricing you edited and only has its upstream refreshed.
|
|
@@ -235,7 +323,8 @@ export async function importEndpointModels(ids, { baseUrl, fetch: f } = {}) {
|
|
|
235
323
|
if (!m) { skipped.push({ id, why: 'the endpoint does not serve it' }); continue; }
|
|
236
324
|
if (!m.importable) { skipped.push({ id, why: m.blocked || 'not usable in a pipeline' }); continue; }
|
|
237
325
|
const entry = catalogEntryForEndpointModel(m, { server: out.server, baseUrl: out.baseUrl, providerBaseUrl: p.baseUrl });
|
|
238
|
-
const current =
|
|
326
|
+
const current = existingEndpointEntry(m.id, out.baseUrl, p.baseUrl)
|
|
327
|
+
|| listGlobalModels().find((x) => x.id.toLowerCase() === entry.id.toLowerCase());
|
|
239
328
|
if (current) {
|
|
240
329
|
if (!current.upstream || current.upstream.provider !== 'openai') { skipped.push({ id, why: `"${current.id}" already exists and is not an OpenAI-compatible entry` }); continue; }
|
|
241
330
|
await updateGlobalModel(current.id, { upstream: { ...current.upstream, model: entry.upstream.model, ...(entry.upstream.baseUrl ? { baseUrl: entry.upstream.baseUrl } : {}), ...(entry.upstream.capabilities ? { capabilities: entry.upstream.capabilities } : {}) } });
|
|
@@ -248,6 +337,60 @@ export async function importEndpointModels(ids, { baseUrl, fetch: f } = {}) {
|
|
|
248
337
|
return { created, updated, skipped, server: out.server, serverLabel: out.serverLabel, baseUrl: out.baseUrl, warnings: out.warnings };
|
|
249
338
|
}
|
|
250
339
|
|
|
340
|
+
// ── OpenRouter: the key's own limits ────────────────────────────────────────
|
|
341
|
+
|
|
342
|
+
const finiteOrNull = (v) => (typeof v === 'number' && Number.isFinite(v) ? v : null);
|
|
343
|
+
|
|
344
|
+
/**
|
|
345
|
+
* What OpenRouter says about the key (GET {base}/key): credit limit and what is left, spend, the
|
|
346
|
+
* free-model daily allowance and the rate limit. The `label` is dropped — OpenRouter builds it from
|
|
347
|
+
* the key itself (`sk-or-v1-abc…xyz`). Never throws: null when there is no key or no answer.
|
|
348
|
+
* @returns {Promise<null|{limit:number|null, limitRemaining:number|null, usage:number|null, usageDaily:number|null,
|
|
349
|
+
* isFreeTier:boolean, freeDaily:{used:number,limit:number,remaining:number}|null, rateLimit:object|null}>}
|
|
350
|
+
*/
|
|
351
|
+
export async function openRouterKeyInfo(baseUrl, apiKey, { fetch: f = globalThis.fetch, timeoutMs = 10_000 } = {}) {
|
|
352
|
+
if (!apiKey) return null;
|
|
353
|
+
try {
|
|
354
|
+
const r = await f(`${String(baseUrl || '').replace(/\/+$/, '')}/key`, { headers: { authorization: `Bearer ${apiKey}` }, signal: AbortSignal.timeout(timeoutMs) });
|
|
355
|
+
if (!r.ok) return null;
|
|
356
|
+
const j = await r.json();
|
|
357
|
+
const d = j && j.data;
|
|
358
|
+
if (!d || typeof d !== 'object') return null;
|
|
359
|
+
const fd = d.free_model_daily_requests;
|
|
360
|
+
const rl = d.rate_limit;
|
|
361
|
+
return {
|
|
362
|
+
limit: finiteOrNull(d.limit),
|
|
363
|
+
limitRemaining: finiteOrNull(d.limit_remaining),
|
|
364
|
+
usage: finiteOrNull(d.usage),
|
|
365
|
+
usageDaily: finiteOrNull(d.usage_daily),
|
|
366
|
+
isFreeTier: d.is_free_tier === true,
|
|
367
|
+
freeDaily: fd && typeof fd === 'object' && finiteOrNull(fd.limit) !== null
|
|
368
|
+
? { used: finiteOrNull(fd.used) ?? 0, limit: fd.limit, remaining: finiteOrNull(fd.remaining) ?? Math.max(0, fd.limit - (finiteOrNull(fd.used) ?? 0)) }
|
|
369
|
+
: null,
|
|
370
|
+
// OpenRouter answers `requests: -1` for a key with no request-rate limit of its own.
|
|
371
|
+
rateLimit: rl && typeof rl === 'object' && finiteOrNull(rl.requests) > 0 ? { requests: rl.requests, interval: String(rl.interval || '') } : null,
|
|
372
|
+
};
|
|
373
|
+
} catch { return null; }
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
/** One line for the Providers card and `worca models test`. Pure. */
|
|
377
|
+
export function formatOpenRouterKeyInfo(info) {
|
|
378
|
+
if (!info) return '';
|
|
379
|
+
const usd = (n) => `$${n.toFixed(2)}`;
|
|
380
|
+
const bits = [];
|
|
381
|
+
if (info.limit !== null && info.limit !== undefined) {
|
|
382
|
+
const left = info.limitRemaining ?? (info.usage !== null && info.usage !== undefined ? Math.max(0, info.limit - info.usage) : null);
|
|
383
|
+
bits.push(left !== null ? `credit ${usd(left)} of ${usd(info.limit)} left` : `credit limit ${usd(info.limit)}`);
|
|
384
|
+
} else {
|
|
385
|
+
bits.push('no credit limit');
|
|
386
|
+
if (info.usage !== null && info.usage !== undefined) bits.push(`${usd(info.usage)} used`);
|
|
387
|
+
}
|
|
388
|
+
if (info.freeDaily) bits.push(`free-model requests today ${info.freeDaily.remaining} / ${info.freeDaily.limit}`);
|
|
389
|
+
if (info.isFreeTier) bits.push('free tier');
|
|
390
|
+
if (info.rateLimit) bits.push(`rate limit ${info.rateLimit.requests} per ${info.rateLimit.interval}`);
|
|
391
|
+
return bits.join(' · ');
|
|
392
|
+
}
|
|
393
|
+
|
|
251
394
|
// ── key-based providers: connection test ────────────────────────────────────
|
|
252
395
|
|
|
253
396
|
/**
|
|
@@ -261,6 +404,11 @@ export async function importEndpointModels(ids, { baseUrl, fetch: f } = {}) {
|
|
|
261
404
|
* @returns {Promise<{ok:true, models?:number}|{ok:false, message:string}>}
|
|
262
405
|
*/
|
|
263
406
|
export async function testProviderConnection(name, { fetch: f = globalThis.fetch, baseUrl = '', apiKey } = {}) {
|
|
407
|
+
// Credential broker: keys are per person and tested where they are saved.
|
|
408
|
+
if (brokerEnabled()) {
|
|
409
|
+
const keyPage = cachedBrokerInfo()?.publicUrl;
|
|
410
|
+
return { ok: false, message: `keys are held by the credential broker: test yours on the key page${keyPage ? ` (${keyPage})` : ''}` };
|
|
411
|
+
}
|
|
264
412
|
if (name === 'copilot') {
|
|
265
413
|
const c = providerConfig('copilot');
|
|
266
414
|
const token = resolveProviderSecret(c.githubToken);
|
|
@@ -287,6 +435,14 @@ export async function testProviderConnection(name, { fetch: f = globalThis.fetch
|
|
|
287
435
|
if (!r.ok) return { ok: false, message: `endpoint answered ${r.status}` };
|
|
288
436
|
const j = await r.json().catch(() => null);
|
|
289
437
|
const n = j && Array.isArray(j.data) ? j.data.length : undefined;
|
|
438
|
+
// OpenRouter lists its models without a key, so a reachable list proves nothing about the key:
|
|
439
|
+
// its /key does, and says what the key may still spend. A key it rejects fails the test.
|
|
440
|
+
if (name === 'openai' && isOpenRouter(base)) {
|
|
441
|
+
if (!key) return { ok: false, message: 'no API key configured — OpenRouter lists models without one, but every call needs it' };
|
|
442
|
+
const info = await openRouterKeyInfo(base, key, { fetch: f });
|
|
443
|
+
if (!info) return { ok: false, message: 'authentication failed — OpenRouter did not accept the key (its /key check failed)' };
|
|
444
|
+
return { ok: true, ...(n !== undefined ? { models: n } : {}), openrouter: info, detail: formatOpenRouterKeyInfo(info) };
|
|
445
|
+
}
|
|
290
446
|
return { ok: true, ...(n !== undefined ? { models: n } : {}) };
|
|
291
447
|
} catch (err) {
|
|
292
448
|
return { ok: false, message: `endpoint unreachable — ${err && err.message ? err.message : String(err)}` };
|
|
@@ -18,6 +18,10 @@
|
|
|
18
18
|
// LM Studio GET {root}/api/v0/models type (llm | vlm | embeddings), state, max_context_length,
|
|
19
19
|
// loaded_context_length.
|
|
20
20
|
// vLLM / other GET {base}/models ids, plus max_model_len where the server sets it.
|
|
21
|
+
// OpenRouter GET {base}/models recognised by its host and never probed: context_length and
|
|
22
|
+
// top_provider (the window it serves, the output cap),
|
|
23
|
+
// supported_parameters (tools, reasoning), modalities and
|
|
24
|
+
// per-token pricing — everything the generic list lacks.
|
|
21
25
|
//
|
|
22
26
|
// The window a model is SERVED with and the one it was TRAINED for are different numbers, and only
|
|
23
27
|
// the served one may become a prompt limit: Ollama serves 4096 by default however large the model
|
|
@@ -25,11 +29,33 @@
|
|
|
25
29
|
// built WITHOUT a prompt limit and the caller's warning says so — a wrong window is worse than
|
|
26
30
|
// none, because the CLI would compact against a number the endpoint never had.
|
|
27
31
|
|
|
32
|
+
import { isLocalBaseUrl } from '../../model-env.mjs';
|
|
33
|
+
import { isOpenRouter } from '../openrouter.mjs';
|
|
34
|
+
|
|
28
35
|
const SERVERS = Object.freeze({
|
|
29
|
-
'llama.cpp': 'llama.cpp', ollama: 'Ollama', lmstudio: 'LM Studio', vllm: 'vLLM', 'openai-compatible': 'OpenAI-compatible',
|
|
36
|
+
'llama.cpp': 'llama.cpp', ollama: 'Ollama', lmstudio: 'LM Studio', vllm: 'vLLM', openrouter: 'OpenRouter', 'openai-compatible': 'OpenAI-compatible',
|
|
30
37
|
});
|
|
31
38
|
/** The catalog-id prefix per server, so two endpoints' models never collide. */
|
|
32
|
-
const ID_PREFIX = Object.freeze({ 'llama.cpp': 'llama', ollama: 'ollama', lmstudio: 'lmstudio', vllm: 'vllm', 'openai-compatible': 'local' });
|
|
39
|
+
const ID_PREFIX = Object.freeze({ 'llama.cpp': 'llama', ollama: 'ollama', lmstudio: 'lmstudio', vllm: 'vllm', openrouter: 'openrouter', 'openai-compatible': 'local' });
|
|
40
|
+
|
|
41
|
+
// Re-exported for the import's callers; the one host rule lives in model-env (via ../openrouter.mjs).
|
|
42
|
+
export { isOpenRouter };
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* The catalog-id prefix for a server at a base URL. A generic list used to be `local-` wherever it
|
|
46
|
+
* lived, so a model on a hosted gateway read as a local one; a remote host now names its own
|
|
47
|
+
* prefix (`api.groq.com` → `groq`), and only a local / private URL keeps `local`.
|
|
48
|
+
*/
|
|
49
|
+
export function endpointIdPrefix(server, baseUrl) {
|
|
50
|
+
if (server !== 'openai-compatible') return ID_PREFIX[server] || 'local';
|
|
51
|
+
if (isLocalBaseUrl(baseUrl)) return 'local';
|
|
52
|
+
let host = '';
|
|
53
|
+
try { host = new URL(String(baseUrl || '').trim()).hostname.toLowerCase(); } catch { return 'local'; }
|
|
54
|
+
const labels = host.split('.').filter(Boolean);
|
|
55
|
+
if (labels.length < 2) return 'local'; // a single-label name (`http://gw/v1`) is on your network
|
|
56
|
+
while (labels.length > 2 && ['api', 'www', 'llm', 'inference'].includes(labels[0])) labels.shift();
|
|
57
|
+
return (labels[0] || '').replace(/[^a-z0-9-]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 16) || 'remote';
|
|
58
|
+
}
|
|
33
59
|
/** Below this a pipeline thrashes the CLI's auto-compact (docs/models.md Troubleshooting). */
|
|
34
60
|
export const MIN_PIPELINE_WINDOW = 65536;
|
|
35
61
|
const DEFAULT_TIMEOUT_MS = 10_000;
|
|
@@ -142,6 +168,56 @@ function openAiModels(list) {
|
|
|
142
168
|
})).filter((m) => m.id);
|
|
143
169
|
}
|
|
144
170
|
|
|
171
|
+
/** A USD-per-token price string → USD per million tokens; null for a missing or negative one. */
|
|
172
|
+
function perMillion(v) {
|
|
173
|
+
const n = Number.parseFloat(v);
|
|
174
|
+
if (!Number.isFinite(n) || n < 0) return null;
|
|
175
|
+
return Math.round(n * 1e12) / 1e6; // 0.000003 × 1e6 is 3.0000000000000004 in floating point
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* OpenRouter's listed price as a catalog `cost`: `{free:true}` for a zero price, `{perMtok}` for a
|
|
180
|
+
* real one, null when the listing has none or a variable one (the Auto router lists -1) — an
|
|
181
|
+
* unknown price is left unset ("cost not verified"), never claimed free.
|
|
182
|
+
*/
|
|
183
|
+
function openRouterPricing(p) {
|
|
184
|
+
if (!p || typeof p !== 'object') return null;
|
|
185
|
+
const input = perMillion(p.prompt); const output = perMillion(p.completion);
|
|
186
|
+
if (input === null || output === null) return null;
|
|
187
|
+
const cacheRead = perMillion(p.input_cache_read); const cacheWrite = perMillion(p.input_cache_write);
|
|
188
|
+
if (!input && !output && !cacheRead && !cacheWrite) return { free: true };
|
|
189
|
+
return { perMtok: { input, output, ...(cacheRead ? { cacheRead } : {}), ...(cacheWrite ? { cacheWrite } : {}) } };
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
const money = (n) => `$${Number(n.toFixed(2))}`;
|
|
193
|
+
|
|
194
|
+
function openRouterModels(list) {
|
|
195
|
+
return (Array.isArray(list && list.data) ? list.data : []).map((m) => {
|
|
196
|
+
const arch = m.architecture || {};
|
|
197
|
+
const top = m.top_provider || {};
|
|
198
|
+
const params = Array.isArray(m.supported_parameters) ? m.supported_parameters : [];
|
|
199
|
+
const outputs = Array.isArray(arch.output_modalities) ? arch.output_modalities : ['text'];
|
|
200
|
+
const pricing = openRouterPricing(m.pricing);
|
|
201
|
+
const ctx = num(top.context_length) ?? num(m.context_length);
|
|
202
|
+
return {
|
|
203
|
+
id: String(m.id ?? ''),
|
|
204
|
+
name: String(m.name || m.id || ''),
|
|
205
|
+
kind: outputs.includes('text') ? 'llm' : 'other',
|
|
206
|
+
// OpenRouter serves the window it lists — unlike Ollama, there is no smaller default behind it.
|
|
207
|
+
servedContext: ctx,
|
|
208
|
+
trainedContext: null,
|
|
209
|
+
maxOutputTokens: num(top.max_completion_tokens),
|
|
210
|
+
toolCalls: params.includes('tools'),
|
|
211
|
+
vision: has(arch.input_modalities, 'image'),
|
|
212
|
+
reasoning: params.includes('reasoning') || params.includes('reasoning_effort'),
|
|
213
|
+
loaded: null,
|
|
214
|
+
pricing,
|
|
215
|
+
free: !!(pricing && pricing.free),
|
|
216
|
+
detail: !pricing ? 'variable price' : pricing.free ? 'free' : `${money(pricing.perMtok.input)} / ${money(pricing.perMtok.output)} per M`,
|
|
217
|
+
};
|
|
218
|
+
}).filter((m) => m.id);
|
|
219
|
+
}
|
|
220
|
+
|
|
145
221
|
/** What the caller must know before pinning a prompt limit on what this server reports. */
|
|
146
222
|
function warningsFor(server, models, props) {
|
|
147
223
|
const w = [];
|
|
@@ -157,6 +233,9 @@ function warningsFor(server, models, props) {
|
|
|
157
233
|
if (server === 'lmstudio' && models.some((m) => m.kind !== 'embedding' && !m.servedContext)) {
|
|
158
234
|
w.push('LM Studio reports a model\'s real window only while it is loaded; for the others the number shown is what the model supports, and the prompt limit is left unset.');
|
|
159
235
|
}
|
|
236
|
+
if (server === 'openrouter' && models.some((m) => m.free)) {
|
|
237
|
+
w.push(':free models run on a pool OpenRouter shares with every user: while it is busy EVERY request gets a 429, however few you send — Max concurrent requests cannot help. For pipelines use the paid variant, add your own provider key on OpenRouter (BYOK), or give the model a fallback in its Connection.');
|
|
238
|
+
}
|
|
160
239
|
if (server === 'openai-compatible' && models.every((m) => !m.servedContext)) {
|
|
161
240
|
w.push('This endpoint does not report context windows — set each model\'s prompt limit by hand after importing.');
|
|
162
241
|
}
|
|
@@ -172,7 +251,7 @@ function warningsFor(server, models, props) {
|
|
|
172
251
|
* @returns {Promise<{server:string, serverLabel:string, baseUrl:string, models:Array<object>, warnings:string[]}>}
|
|
173
252
|
* @throws {Error} when nothing answers at all (the caller shows it as the connection failure it is)
|
|
174
253
|
*/
|
|
175
|
-
export async function listEndpointModels(baseUrl, { apiKey = '', fetch: f = globalThis.fetch, timeoutMs = DEFAULT_TIMEOUT_MS } = {}) {
|
|
254
|
+
export async function listEndpointModels(baseUrl, { apiKey = '', fetch: f = globalThis.fetch, timeoutMs = DEFAULT_TIMEOUT_MS, realBaseUrl = null } = {}) {
|
|
176
255
|
const base = String(baseUrl || '').trim().replace(/\/+$/, '');
|
|
177
256
|
if (!base) throw new Error('baseUrl is required');
|
|
178
257
|
const root = endpointRoot(base);
|
|
@@ -182,6 +261,18 @@ export async function listEndpointModels(baseUrl, { apiKey = '', fetch: f = glob
|
|
|
182
261
|
let models = [];
|
|
183
262
|
let props = null;
|
|
184
263
|
|
|
264
|
+
// `realBaseUrl`: the provider URL when `baseUrl` is the credential broker's slot for it —
|
|
265
|
+
// what the server IS (OpenRouter or not) is decided by the real one.
|
|
266
|
+
if (isOpenRouter(realBaseUrl || base)) {
|
|
267
|
+
// A hosted API: probing it for llama.cpp / Ollama / LM Studio would cost three requests for
|
|
268
|
+
// three 404s. Its own /models is the whole answer.
|
|
269
|
+
const list = await json(f, `${base}/models`, opt);
|
|
270
|
+
if (!list) throw new Error(`no OpenAI-compatible model list at ${base}/models — is the base URL right?`);
|
|
271
|
+
models = openRouterModels(list);
|
|
272
|
+
models.sort((a, b) => (Number(b.kind === 'llm') - Number(a.kind === 'llm')) || a.id.localeCompare(b.id));
|
|
273
|
+
return { server: 'openrouter', serverLabel: SERVERS.openrouter, baseUrl: base, models, warnings: warningsFor('openrouter', models, null) };
|
|
274
|
+
}
|
|
275
|
+
|
|
185
276
|
const props0 = await json(f, `${root}/props`, opt);
|
|
186
277
|
if (props0 && (props0.default_generation_settings || props0.chat_template_caps)) {
|
|
187
278
|
server = 'llama.cpp';
|
|
@@ -221,21 +312,34 @@ export async function listEndpointModels(baseUrl, { apiKey = '', fetch: f = glob
|
|
|
221
312
|
* A discovered model as a catalog entry (§8.4's Copilot shape, for a server you run).
|
|
222
313
|
* `baseUrl` rides the ENTRY when it differs from the provider's, so one catalog can hold an Ollama
|
|
223
314
|
* and a llama.cpp model at once. A prompt limit is pinned only from a window the server really
|
|
224
|
-
* serves; `maxOutputTokens` is never guessed — no local server reports one.
|
|
315
|
+
* serves; `maxOutputTokens` is never guessed — no local server reports one. OpenRouter does, and
|
|
316
|
+
* its cap is pinned only while it leaves at least half the window for the prompt: a cap that is
|
|
317
|
+
* most of the window (235929 of 262144) becomes the CLI's max_tokens, and prompt + max_tokens
|
|
318
|
+
* then overflows the window on the first large turn.
|
|
225
319
|
* @param {object} m a row from listEndpointModels
|
|
226
320
|
* @param {{server:string, baseUrl:string, providerBaseUrl?:string}} ctx
|
|
227
321
|
*/
|
|
228
322
|
export function catalogEntryForEndpointModel(m, { server = 'openai-compatible', baseUrl, providerBaseUrl = '' } = {}) {
|
|
323
|
+
const window = num(m.servedContext);
|
|
324
|
+
const outCap = num(m.maxOutputTokens);
|
|
229
325
|
const capabilities = {
|
|
230
326
|
...(m.toolCalls === true || m.toolCalls === false ? { toolCalls: m.toolCalls } : {}),
|
|
231
327
|
...(m.vision === true ? { vision: true } : {}),
|
|
232
328
|
...(m.reasoning === true ? { reasoning: true } : {}),
|
|
233
|
-
...(
|
|
329
|
+
...(window ? { maxPromptTokens: window } : {}),
|
|
330
|
+
...(outCap && window && outCap <= window / 2 ? { maxOutputTokens: outCap } : {}),
|
|
234
331
|
};
|
|
235
332
|
const own = String(baseUrl || '').replace(/\/+$/, '');
|
|
236
333
|
const provider = String(providerBaseUrl || '').replace(/\/+$/, '');
|
|
334
|
+
const hosted = server === 'openrouter';
|
|
335
|
+
// A hosted catalog keeps the vendor in the id (`qwen/…`, `anthropic/…`): the last path segment
|
|
336
|
+
// alone collides across vendors there. A local server's ids stay as they were.
|
|
337
|
+
const stem = hosted ? String(m.id).toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 64) || 'model' : slugModelId(m.id);
|
|
338
|
+
// A model on your own machine bills nothing; OpenRouter lists its price, and one it lists as
|
|
339
|
+
// variable is left unset rather than claimed free.
|
|
340
|
+
const cost = hosted ? (m.pricing || undefined) : { free: true };
|
|
237
341
|
return {
|
|
238
|
-
id: `${
|
|
342
|
+
id: `${endpointIdPrefix(server, own)}-${stem}`,
|
|
239
343
|
label: `${m.name || m.id} (${SERVERS[server] || 'local'})`,
|
|
240
344
|
...(m.reasoning === true ? {} : { efforts: ['medium'] }),
|
|
241
345
|
upstream: {
|
|
@@ -243,13 +347,14 @@ export function catalogEntryForEndpointModel(m, { server = 'openai-compatible',
|
|
|
243
347
|
...(own && own !== provider ? { baseUrl: own } : {}),
|
|
244
348
|
...(Object.keys(capabilities).length ? { capabilities } : {}),
|
|
245
349
|
},
|
|
246
|
-
cost
|
|
350
|
+
...(cost ? { cost } : {}),
|
|
247
351
|
};
|
|
248
352
|
}
|
|
249
353
|
|
|
250
354
|
/** Whether a discovered model can carry a pipeline at all (the sheet greys the rest). */
|
|
251
355
|
export function importableModel(m) {
|
|
252
356
|
if (!m || m.kind === 'embedding') return { ok: false, why: 'an embedding model — not a chat model' };
|
|
357
|
+
if (m.kind === 'other') return { ok: false, why: 'does not reply in text — not a chat model' };
|
|
253
358
|
if (m.toolCalls === false) return { ok: false, why: 'no tool calls — a pipeline agent cannot run without them' };
|
|
254
359
|
return { ok: true };
|
|
255
360
|
}
|
|
@@ -9,6 +9,8 @@ import { listGlobalModels, providerConfig, providerSecretSet, resolveProviderSec
|
|
|
9
9
|
import { listPluginModels } from '../plugin-models.mjs';
|
|
10
10
|
import { policyCatalogModels } from '../policy/cache.mjs';
|
|
11
11
|
import { isLocalBaseUrl } from '../model-env.mjs';
|
|
12
|
+
import { brokerEnabled } from '../broker-client.mjs';
|
|
13
|
+
import { routeBridgedUpstream } from '../broker-routing.mjs';
|
|
12
14
|
|
|
13
15
|
/** An OpenAI-compatible endpoint on this machine / a private network needs no key. */
|
|
14
16
|
export function keyOptional(provider, baseUrl) {
|
|
@@ -42,6 +44,16 @@ export function findBridgedEntry(id) {
|
|
|
42
44
|
export function providerReadiness(upstream) {
|
|
43
45
|
if (!upstream) return { ok: true };
|
|
44
46
|
const p = upstream.provider;
|
|
47
|
+
// Credential broker: worca holds no provider key or GitHub sign-in. Ready when the model
|
|
48
|
+
// maps to a broker slot (or is a keyless local endpoint); whether the PERSON has a key
|
|
49
|
+
// is the broker's question, answered per spawn. Copilot's terms stay an install setting.
|
|
50
|
+
if (brokerEnabled()) {
|
|
51
|
+
if (p === 'copilot' && !copilotTermsAcknowledged()) {
|
|
52
|
+
return { ok: false, reason: 'terms', message: 'provider copilot: terms not acknowledged — open Settings › Providers' };
|
|
53
|
+
}
|
|
54
|
+
const r = routeBridgedUpstream(upstream);
|
|
55
|
+
return r.error ? { ok: false, reason: 'no_key', message: `provider ${p}: ${r.error}` } : { ok: true };
|
|
56
|
+
}
|
|
45
57
|
if (p === 'copilot') {
|
|
46
58
|
if (!copilotTermsAcknowledged()) {
|
|
47
59
|
return { ok: false, reason: 'terms', message: 'provider copilot: terms not acknowledged — open Settings › Providers' };
|
|
@@ -83,6 +95,7 @@ export function upstreamSettings(upstream) {
|
|
|
83
95
|
accountType: p === 'copilot' ? cfg.accountType : null,
|
|
84
96
|
headers: upstream.headers || {},
|
|
85
97
|
capabilities: upstream.capabilities || {},
|
|
98
|
+
...(upstream.openrouter ? { openrouter: upstream.openrouter } : {}),
|
|
86
99
|
maxConcurrent: cfg.maxConcurrent,
|
|
87
100
|
};
|
|
88
101
|
}
|
|
@@ -18,6 +18,7 @@ import { findBridgedEntry } from './registry.mjs';
|
|
|
18
18
|
import { handleMessages } from './upstream.mjs';
|
|
19
19
|
import { estimateInputTokens } from './translate/response.mjs';
|
|
20
20
|
import { bridgeErrors, PAYLOAD_CEILING_BYTES } from './errors.mjs';
|
|
21
|
+
import { brokerEnabled } from '../broker-client.mjs';
|
|
21
22
|
|
|
22
23
|
let state = null; // { server, port, secret, listening: Promise<number>, log }
|
|
23
24
|
const ROUTE_RE = /^\/m\/([^/]+)(?:\/r\/([^/]+))?\/v1\/(messages|messages\/count_tokens|models)\/?$/;
|
|
@@ -132,18 +133,30 @@ function sendJson(res, status, obj, headers) {
|
|
|
132
133
|
res.end(JSON.stringify(obj));
|
|
133
134
|
}
|
|
134
135
|
|
|
136
|
+
const BROKER_TOKEN_RE = /^wbt_[A-Za-z0-9_-]{43}$/;
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* The caller's credential: the bridge's own secret, or — with the credential broker on —
|
|
140
|
+
* the spawn's broker token, which the bridge forwards to the broker (it is the broker
|
|
141
|
+
* that checks it; the bridge never holds a provider key then). null = refused.
|
|
142
|
+
* @returns {{brokerToken:string|null}|null}
|
|
143
|
+
*/
|
|
135
144
|
function authorized(req) {
|
|
136
145
|
const h = req.headers;
|
|
137
146
|
const bearer = typeof h.authorization === 'string' && /^bearer\s+/i.test(h.authorization) ? h.authorization.replace(/^bearer\s+/i, '').trim() : '';
|
|
138
147
|
const key = typeof h['x-api-key'] === 'string' ? h['x-api-key'].trim() : '';
|
|
139
|
-
|
|
148
|
+
if ((bearer && bearer === state.secret) || (key && key === state.secret)) return { brokerToken: null };
|
|
149
|
+
const tok = bearer || key;
|
|
150
|
+
if (brokerEnabled() && BROKER_TOKEN_RE.test(tok)) return { brokerToken: tok };
|
|
151
|
+
return null;
|
|
140
152
|
}
|
|
141
153
|
|
|
142
154
|
async function handle(req, res) {
|
|
143
155
|
const url = new URL(req.url || '/', 'http://127.0.0.1');
|
|
144
156
|
const m = ROUTE_RE.exec(url.pathname);
|
|
145
157
|
if (!m) { const e = bridgeErrors.notFound(); return sendJson(res, e.status, e.body); }
|
|
146
|
-
|
|
158
|
+
const auth = authorized(req);
|
|
159
|
+
if (!auth) { const e = bridgeErrors.unauthorized(); return sendJson(res, e.status, e.body); }
|
|
147
160
|
const catalogId = decodeURIComponent(m[1]);
|
|
148
161
|
const tag = m[2] ? decodeURIComponent(m[2]) : '';
|
|
149
162
|
const route = m[3];
|
|
@@ -180,5 +193,5 @@ async function handle(req, res) {
|
|
|
180
193
|
};
|
|
181
194
|
const headers = {};
|
|
182
195
|
for (const [k, v] of Object.entries(req.headers)) if (typeof v === 'string') headers[k.toLowerCase()] = v;
|
|
183
|
-
await handleMessages({ entry, body, requestHeaders: headers, tag, signal: ctrl.signal, fetch: state.fetch, log: state.log }, reply);
|
|
196
|
+
await handleMessages({ entry, body, requestHeaders: headers, tag, signal: ctrl.signal, fetch: state.fetch, log: state.log, brokerToken: auth.brokerToken }, reply);
|
|
184
197
|
}
|
|
@@ -13,7 +13,7 @@ import { EventEmitter } from 'node:events';
|
|
|
13
13
|
export const bridgeEvents = new EventEmitter();
|
|
14
14
|
bridgeEvents.setMaxListeners(50);
|
|
15
15
|
|
|
16
|
-
const calls = new Map(); // tag -> { initiated, continued, errors }
|
|
16
|
+
const calls = new Map(); // tag -> { initiated, continued, errors, free }
|
|
17
17
|
const MAX_TAGS = 5000;
|
|
18
18
|
|
|
19
19
|
function slot(tag) {
|
|
@@ -21,33 +21,65 @@ function slot(tag) {
|
|
|
21
21
|
let s = calls.get(k);
|
|
22
22
|
if (!s) {
|
|
23
23
|
if (calls.size >= MAX_TAGS) calls.delete(calls.keys().next().value);
|
|
24
|
-
s = { initiated: 0, continued: 0, errors: 0 };
|
|
24
|
+
s = { initiated: 0, continued: 0, errors: 0, free: 0 };
|
|
25
25
|
calls.set(k, s);
|
|
26
26
|
}
|
|
27
27
|
return s;
|
|
28
28
|
}
|
|
29
29
|
|
|
30
|
-
/**
|
|
31
|
-
|
|
30
|
+
/**
|
|
31
|
+
* Book one upstream call. `initiator` is 'user' | 'agent' (§7.1). `free`: an OpenRouter
|
|
32
|
+
* `:free` model, where EVERY call (continuations too) spends one of the day's free requests
|
|
33
|
+
* (openrouter-free.mjs); `account` names the key it spent from, when the bridge knows it.
|
|
34
|
+
*/
|
|
35
|
+
export function recordBridgeCall({ tag, catalogId, provider, api, initiator, free = false, account = null }) {
|
|
32
36
|
const s = slot(tag);
|
|
33
37
|
if (initiator === 'agent') s.continued += 1; else s.initiated += 1;
|
|
34
|
-
|
|
38
|
+
if (free) s.free += 1;
|
|
39
|
+
bridgeEvents.emit('call', { tag: tag || '', catalogId, provider, api, initiator, free, account });
|
|
35
40
|
}
|
|
36
41
|
|
|
37
42
|
/** Book one failed upstream call. */
|
|
38
|
-
export function recordBridgeError({ tag, catalogId, provider, status, message }) {
|
|
43
|
+
export function recordBridgeError({ tag, catalogId, provider, status, message, account = null }) {
|
|
39
44
|
slot(tag).errors += 1;
|
|
40
|
-
bridgeEvents.emit('failure', { tag: tag || '', catalogId, provider, status, message });
|
|
45
|
+
bridgeEvents.emit('failure', { tag: tag || '', catalogId, provider, status, message, account });
|
|
41
46
|
}
|
|
42
47
|
|
|
43
|
-
/** Counters for a tag: {initiated, continued, errors}; zeros when unseen. */
|
|
48
|
+
/** Counters for a tag: {initiated, continued, errors, free}; zeros when unseen. */
|
|
44
49
|
export function bridgeCallsFor(tag) {
|
|
45
50
|
const s = calls.get(tag || '');
|
|
46
|
-
return s ? { ...s } : { initiated: 0, continued: 0, errors: 0 };
|
|
51
|
+
return s ? { ...s } : { initiated: 0, continued: 0, errors: 0, free: 0 };
|
|
47
52
|
}
|
|
48
53
|
|
|
49
|
-
|
|
50
|
-
|
|
54
|
+
// The USD an upstream itself reported for a tag's calls (OpenRouter's
|
|
55
|
+
// usage.cost). Kept apart from the call counters: a priced call is the
|
|
56
|
+
// exception, and the run harness prefers this figure over the CLI's $0.
|
|
57
|
+
const costs = new Map(); // tag -> { costUsd, calls }
|
|
58
|
+
|
|
59
|
+
/** Book the cost one upstream call reported. Ignores anything but a finite, non-negative number. */
|
|
60
|
+
export function recordBridgeCost({ tag, costUsd }) {
|
|
61
|
+
const n = Number(costUsd);
|
|
62
|
+
if (costUsd == null || !Number.isFinite(n) || n < 0) return;
|
|
63
|
+
const k = tag || '';
|
|
64
|
+
let c = costs.get(k);
|
|
65
|
+
if (!c) {
|
|
66
|
+
if (costs.size >= MAX_TAGS) costs.delete(costs.keys().next().value);
|
|
67
|
+
c = { costUsd: 0, calls: 0 };
|
|
68
|
+
costs.set(k, c);
|
|
69
|
+
}
|
|
70
|
+
// Rounded to 1e-9 USD: summing float fractions of a cent would otherwise drift.
|
|
71
|
+
c.costUsd = Math.round((c.costUsd + n) * 1e9) / 1e9;
|
|
72
|
+
c.calls += 1;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** The upstream-reported cost for a tag: {costUsd, calls}, or null when no call reported one. */
|
|
76
|
+
export function bridgeCostFor(tag) {
|
|
77
|
+
const c = costs.get(tag || '');
|
|
78
|
+
return c ? { ...c } : null;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** Forget a tag's counters and cost (a finished run). */
|
|
82
|
+
export function forgetBridgeTag(tag) { calls.delete(tag || ''); costs.delete(tag || ''); }
|
|
51
83
|
|
|
52
84
|
/** Test hook. */
|
|
53
|
-
export function _resetBridgeTelemetry() { calls.clear(); }
|
|
85
|
+
export function _resetBridgeTelemetry() { calls.clear(); costs.clear(); }
|