@worca/app 1.5.0 → 1.6.0-rc.2
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 +8 -2
- 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 +56 -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 +202 -10
- 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 +271 -54
- package/ui/public/ask-model.mjs +1 -0
- package/ui/public/ask-panel.mjs +160 -23
- package/ui/public/bridge-view.mjs +216 -9
- 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 +303 -244
- 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 +110 -54
- 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, personSlots } 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
|
}
|
|
@@ -87,7 +129,7 @@ const secretSource = (v) => (!v ? null : modelEnvRef(v) ? 'env' : 'stored');
|
|
|
87
129
|
* a network call to GitHub — and is null when the account exposes none.
|
|
88
130
|
* @param {{quota?:boolean, fetch?:typeof fetch}} [opts]
|
|
89
131
|
*/
|
|
90
|
-
export async function providersState({ quota = false, fetch: f } = {}) {
|
|
132
|
+
export async function providersState({ quota = false, fetch: f, person = resolveBillTo(null) } = {}) {
|
|
91
133
|
const all = allProviders();
|
|
92
134
|
const c = all.copilot;
|
|
93
135
|
const token = resolveProviderSecret(c.githubToken);
|
|
@@ -119,7 +161,45 @@ 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
|
+
// each provider's state is the viewer's own slot there, not worca's (empty) settings.
|
|
166
|
+
const broker = brokerEnabled() ? await brokerProviders(all, person) : { enabled: false };
|
|
167
|
+
return { copilot, openai: keyed('openai'), anthropic: keyed('anthropic'), broker };
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* The Providers card's view of the broker: which slot each provider spends from and the viewer's
|
|
172
|
+
* state in it — {slot, state:'set'|'missing'|'invalid'|'operator'|'keyless'|'none', suffix?, error?}.
|
|
173
|
+
* Never throws: an unreachable broker leaves the states out and says why.
|
|
174
|
+
*/
|
|
175
|
+
async function brokerProviders(all, person) {
|
|
176
|
+
const info = cachedBrokerInfo() || await brokerInfo().catch(() => null);
|
|
177
|
+
const out = { enabled: true, keyPage: info?.publicUrl || null, mode: info?.mode || null, providers: {} };
|
|
178
|
+
const slots = info?.slots || [];
|
|
179
|
+
const route = (name) => {
|
|
180
|
+
if (name === 'copilot') {
|
|
181
|
+
const s = slots.find((x) => x.auth === 'copilot');
|
|
182
|
+
return s ? { slot: s.id } : { state: 'none', error: 'the credential broker has no GitHub Copilot slot' };
|
|
183
|
+
}
|
|
184
|
+
const r = routeUpstream({ provider: name, baseUrl: all[name].baseUrl || null }, { slots });
|
|
185
|
+
return r.slot ? { slot: r.slot } : r.keyless ? { state: 'keyless' } : { state: 'none', error: r.error };
|
|
186
|
+
};
|
|
187
|
+
for (const name of ['copilot', 'openai', 'anthropic']) out.providers[name] = route(name);
|
|
188
|
+
const who = out.mode === 'multi' ? person : 'local';
|
|
189
|
+
if (!who || (out.mode === 'multi' && who === 'local')) { out.signInNeeded = true; return out; }
|
|
190
|
+
try {
|
|
191
|
+
const mine = new Map(((await personSlots(who)).slots || []).map((x) => [x.id, x]));
|
|
192
|
+
for (const p of Object.values(out.providers)) {
|
|
193
|
+
if (!p.slot) continue;
|
|
194
|
+
const s = mine.get(p.slot);
|
|
195
|
+
p.state = s ? s.state : 'missing';
|
|
196
|
+
if (s && s.suffix) p.suffix = s.suffix;
|
|
197
|
+
if (s && s.label) p.label = s.label;
|
|
198
|
+
}
|
|
199
|
+
} catch (err) {
|
|
200
|
+
out.error = err.message;
|
|
201
|
+
}
|
|
202
|
+
return out;
|
|
123
203
|
}
|
|
124
204
|
|
|
125
205
|
/** Patch a provider from the UI/CLI; masked key echoes are dropped ("keep"). */
|
|
@@ -127,6 +207,8 @@ export async function patchProvider(name, patch = {}) {
|
|
|
127
207
|
if (!UPSTREAM_PROVIDERS.includes(name)) throw new Error(`unknown provider ${JSON.stringify(name)}`);
|
|
128
208
|
const p = { ...patch };
|
|
129
209
|
for (const k of ['apiKey', 'githubToken']) if (typeof p[k] === 'string' && p[k].startsWith('••')) delete p[k];
|
|
210
|
+
// Credential broker: keys are per person, on the key page; worca stores none.
|
|
211
|
+
if (brokerEnabled() && ['apiKey', 'githubToken'].some((k) => typeof p[k] === 'string' && p[k].trim())) refuseWithBroker('store a provider key');
|
|
130
212
|
return updateProvider(name, p);
|
|
131
213
|
}
|
|
132
214
|
|
|
@@ -134,10 +216,24 @@ export async function patchProvider(name, patch = {}) {
|
|
|
134
216
|
|
|
135
217
|
/** Copilot's models for the import sheet, each with `inCatalog` (§8.4). */
|
|
136
218
|
export async function copilotModelsForImport({ fetch: f } = {}) {
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
219
|
+
let list;
|
|
220
|
+
if (brokerEnabled()) {
|
|
221
|
+
// The signed-in person's Copilot, through the broker (their GitHub sign-in lives there).
|
|
222
|
+
const route = routeUpstream({ provider: 'copilot' }, { slots: (await brokerInfo()).slots || [] });
|
|
223
|
+
if (route.error) throw new Error(route.error);
|
|
224
|
+
list = await withAuxBrokerToken(route.slot, async (token) => {
|
|
225
|
+
const r = await (f || globalThis.fetch)(`${slotBaseUrl(route.slot)}/models`, { headers: copilotHeaders(token) });
|
|
226
|
+
if (!r.ok) throw Object.assign(new Error(await brokerFailure(r, 'Copilot /models')), { status: r.status });
|
|
227
|
+
const j = await r.json();
|
|
228
|
+
const data = Array.isArray(j.data) ? j.data : (Array.isArray(j) ? j : []);
|
|
229
|
+
return data.map(normalizeCopilotModel).filter((m) => m && (!m.type || m.type === 'chat'));
|
|
230
|
+
});
|
|
231
|
+
} else {
|
|
232
|
+
const c = providerConfig('copilot');
|
|
233
|
+
const token = resolveProviderSecret(c.githubToken);
|
|
234
|
+
if (!token) throw Object.assign(new Error('not signed in to GitHub Copilot'), { code: 'NOT_SIGNED_IN' });
|
|
235
|
+
list = await listCopilotModels(token, { accountType: c.accountType, fetch: f });
|
|
236
|
+
}
|
|
141
237
|
const have = new Set(listGlobalModels().map((m) => m.id.toLowerCase()));
|
|
142
238
|
return list.map((m) => ({ ...m, api: copilotApiFor(m), catalogId: `copilot-${m.id}`, inCatalog: have.has(`copilot-${m.id}`.toLowerCase()) }))
|
|
143
239
|
.sort((a, b) => (Number(b.pickerEnabled) - Number(a.pickerEnabled)) || a.name.localeCompare(b.name));
|
|
@@ -204,19 +300,47 @@ function endpointBase(baseUrl) {
|
|
|
204
300
|
export async function endpointModelsForImport({ baseUrl, fetch: f } = {}) {
|
|
205
301
|
const base = endpointBase(baseUrl);
|
|
206
302
|
const p = providerConfig('openai');
|
|
207
|
-
|
|
208
|
-
const
|
|
303
|
+
let out;
|
|
304
|
+
const route = brokerEnabled() ? routeUpstream({ provider: 'openai', baseUrl: base }, { slots: (await brokerInfo()).slots || [] }) : null;
|
|
305
|
+
if (route && route.error) throw new Error(route.error);
|
|
306
|
+
if (route && route.slot) {
|
|
307
|
+
// An endpoint behind a broker slot is asked with the clicking person's key; the listing
|
|
308
|
+
// then names the REAL base URL, so the imported entries point at it.
|
|
309
|
+
out = await withAuxBrokerToken(route.slot, (token) => listEndpointModels(`${slotBaseUrl(route.slot)}${route.prefix}`, { apiKey: token, fetch: f, realBaseUrl: base }));
|
|
310
|
+
out = { ...out, baseUrl: base };
|
|
311
|
+
} else {
|
|
312
|
+
// Keyless local endpoints are reached directly, as without the broker.
|
|
313
|
+
const key = brokerEnabled() ? '' : resolveProviderSecret(p.apiKey);
|
|
314
|
+
out = await listEndpointModels(base, { apiKey: key, fetch: f });
|
|
315
|
+
}
|
|
209
316
|
const have = new Set(listGlobalModels().map((m) => m.id.toLowerCase()));
|
|
210
317
|
return {
|
|
211
318
|
...out,
|
|
212
319
|
models: out.models.map((m) => {
|
|
213
320
|
const entry = catalogEntryForEndpointModel(m, { server: out.server, baseUrl: out.baseUrl, providerBaseUrl: p.baseUrl });
|
|
214
321
|
const usable = importableModel(m);
|
|
215
|
-
|
|
322
|
+
const existing = existingEndpointEntry(m.id, out.baseUrl, p.baseUrl);
|
|
323
|
+
const catalogId = existing ? existing.id : entry.id;
|
|
324
|
+
return { ...m, catalogId, inCatalog: !!existing || have.has(entry.id.toLowerCase()), importable: usable.ok, ...(usable.ok ? {} : { blocked: usable.why }) };
|
|
216
325
|
}),
|
|
217
326
|
};
|
|
218
327
|
}
|
|
219
328
|
|
|
329
|
+
const trimUrl = (u) => String(u || '').trim().replace(/\/+$/, '').toLowerCase();
|
|
330
|
+
|
|
331
|
+
/**
|
|
332
|
+
* The catalog entry that already imports `upstreamId` from this endpoint, whatever its id says.
|
|
333
|
+
* Ids are derived (and their prefix changed: a remote endpoint's models were `local-…`), so a
|
|
334
|
+
* re-import matches on what the entry POINTS AT — the openai provider, this upstream id, and this
|
|
335
|
+
* base URL (its own, or the provider's when it carries none) — and refreshes the entry the user
|
|
336
|
+
* already has instead of adding a twin under the new id.
|
|
337
|
+
*/
|
|
338
|
+
function existingEndpointEntry(upstreamId, baseUrl, providerBaseUrl) {
|
|
339
|
+
const want = trimUrl(baseUrl);
|
|
340
|
+
return listGlobalModels().find((x) => x.upstream && x.upstream.provider === 'openai'
|
|
341
|
+
&& x.upstream.model === upstreamId && trimUrl(x.upstream.baseUrl || providerBaseUrl) === want) || null;
|
|
342
|
+
}
|
|
343
|
+
|
|
220
344
|
/**
|
|
221
345
|
* Import endpoint models into the catalog. A new id gets the full entry; an existing one keeps the
|
|
222
346
|
* label, efforts and pricing you edited and only has its upstream refreshed.
|
|
@@ -235,7 +359,8 @@ export async function importEndpointModels(ids, { baseUrl, fetch: f } = {}) {
|
|
|
235
359
|
if (!m) { skipped.push({ id, why: 'the endpoint does not serve it' }); continue; }
|
|
236
360
|
if (!m.importable) { skipped.push({ id, why: m.blocked || 'not usable in a pipeline' }); continue; }
|
|
237
361
|
const entry = catalogEntryForEndpointModel(m, { server: out.server, baseUrl: out.baseUrl, providerBaseUrl: p.baseUrl });
|
|
238
|
-
const current =
|
|
362
|
+
const current = existingEndpointEntry(m.id, out.baseUrl, p.baseUrl)
|
|
363
|
+
|| listGlobalModels().find((x) => x.id.toLowerCase() === entry.id.toLowerCase());
|
|
239
364
|
if (current) {
|
|
240
365
|
if (!current.upstream || current.upstream.provider !== 'openai') { skipped.push({ id, why: `"${current.id}" already exists and is not an OpenAI-compatible entry` }); continue; }
|
|
241
366
|
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 +373,60 @@ export async function importEndpointModels(ids, { baseUrl, fetch: f } = {}) {
|
|
|
248
373
|
return { created, updated, skipped, server: out.server, serverLabel: out.serverLabel, baseUrl: out.baseUrl, warnings: out.warnings };
|
|
249
374
|
}
|
|
250
375
|
|
|
376
|
+
// ── OpenRouter: the key's own limits ────────────────────────────────────────
|
|
377
|
+
|
|
378
|
+
const finiteOrNull = (v) => (typeof v === 'number' && Number.isFinite(v) ? v : null);
|
|
379
|
+
|
|
380
|
+
/**
|
|
381
|
+
* What OpenRouter says about the key (GET {base}/key): credit limit and what is left, spend, the
|
|
382
|
+
* free-model daily allowance and the rate limit. The `label` is dropped — OpenRouter builds it from
|
|
383
|
+
* the key itself (`sk-or-v1-abc…xyz`). Never throws: null when there is no key or no answer.
|
|
384
|
+
* @returns {Promise<null|{limit:number|null, limitRemaining:number|null, usage:number|null, usageDaily:number|null,
|
|
385
|
+
* isFreeTier:boolean, freeDaily:{used:number,limit:number,remaining:number}|null, rateLimit:object|null}>}
|
|
386
|
+
*/
|
|
387
|
+
export async function openRouterKeyInfo(baseUrl, apiKey, { fetch: f = globalThis.fetch, timeoutMs = 10_000 } = {}) {
|
|
388
|
+
if (!apiKey) return null;
|
|
389
|
+
try {
|
|
390
|
+
const r = await f(`${String(baseUrl || '').replace(/\/+$/, '')}/key`, { headers: { authorization: `Bearer ${apiKey}` }, signal: AbortSignal.timeout(timeoutMs) });
|
|
391
|
+
if (!r.ok) return null;
|
|
392
|
+
const j = await r.json();
|
|
393
|
+
const d = j && j.data;
|
|
394
|
+
if (!d || typeof d !== 'object') return null;
|
|
395
|
+
const fd = d.free_model_daily_requests;
|
|
396
|
+
const rl = d.rate_limit;
|
|
397
|
+
return {
|
|
398
|
+
limit: finiteOrNull(d.limit),
|
|
399
|
+
limitRemaining: finiteOrNull(d.limit_remaining),
|
|
400
|
+
usage: finiteOrNull(d.usage),
|
|
401
|
+
usageDaily: finiteOrNull(d.usage_daily),
|
|
402
|
+
isFreeTier: d.is_free_tier === true,
|
|
403
|
+
freeDaily: fd && typeof fd === 'object' && finiteOrNull(fd.limit) !== null
|
|
404
|
+
? { used: finiteOrNull(fd.used) ?? 0, limit: fd.limit, remaining: finiteOrNull(fd.remaining) ?? Math.max(0, fd.limit - (finiteOrNull(fd.used) ?? 0)) }
|
|
405
|
+
: null,
|
|
406
|
+
// OpenRouter answers `requests: -1` for a key with no request-rate limit of its own.
|
|
407
|
+
rateLimit: rl && typeof rl === 'object' && finiteOrNull(rl.requests) > 0 ? { requests: rl.requests, interval: String(rl.interval || '') } : null,
|
|
408
|
+
};
|
|
409
|
+
} catch { return null; }
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
/** One line for the Providers card and `worca models test`. Pure. */
|
|
413
|
+
export function formatOpenRouterKeyInfo(info) {
|
|
414
|
+
if (!info) return '';
|
|
415
|
+
const usd = (n) => `$${n.toFixed(2)}`;
|
|
416
|
+
const bits = [];
|
|
417
|
+
if (info.limit !== null && info.limit !== undefined) {
|
|
418
|
+
const left = info.limitRemaining ?? (info.usage !== null && info.usage !== undefined ? Math.max(0, info.limit - info.usage) : null);
|
|
419
|
+
bits.push(left !== null ? `credit ${usd(left)} of ${usd(info.limit)} left` : `credit limit ${usd(info.limit)}`);
|
|
420
|
+
} else {
|
|
421
|
+
bits.push('no credit limit');
|
|
422
|
+
if (info.usage !== null && info.usage !== undefined) bits.push(`${usd(info.usage)} used`);
|
|
423
|
+
}
|
|
424
|
+
if (info.freeDaily) bits.push(`free-model requests today ${info.freeDaily.remaining} / ${info.freeDaily.limit}`);
|
|
425
|
+
if (info.isFreeTier) bits.push('free tier');
|
|
426
|
+
if (info.rateLimit) bits.push(`rate limit ${info.rateLimit.requests} per ${info.rateLimit.interval}`);
|
|
427
|
+
return bits.join(' · ');
|
|
428
|
+
}
|
|
429
|
+
|
|
251
430
|
// ── key-based providers: connection test ────────────────────────────────────
|
|
252
431
|
|
|
253
432
|
/**
|
|
@@ -261,6 +440,11 @@ export async function importEndpointModels(ids, { baseUrl, fetch: f } = {}) {
|
|
|
261
440
|
* @returns {Promise<{ok:true, models?:number}|{ok:false, message:string}>}
|
|
262
441
|
*/
|
|
263
442
|
export async function testProviderConnection(name, { fetch: f = globalThis.fetch, baseUrl = '', apiKey } = {}) {
|
|
443
|
+
// Credential broker: keys are per person and tested where they are saved.
|
|
444
|
+
if (brokerEnabled()) {
|
|
445
|
+
const keyPage = cachedBrokerInfo()?.publicUrl;
|
|
446
|
+
return { ok: false, message: `keys are held by the credential broker: test yours on the key page${keyPage ? ` (${keyPage})` : ''}` };
|
|
447
|
+
}
|
|
264
448
|
if (name === 'copilot') {
|
|
265
449
|
const c = providerConfig('copilot');
|
|
266
450
|
const token = resolveProviderSecret(c.githubToken);
|
|
@@ -287,6 +471,14 @@ export async function testProviderConnection(name, { fetch: f = globalThis.fetch
|
|
|
287
471
|
if (!r.ok) return { ok: false, message: `endpoint answered ${r.status}` };
|
|
288
472
|
const j = await r.json().catch(() => null);
|
|
289
473
|
const n = j && Array.isArray(j.data) ? j.data.length : undefined;
|
|
474
|
+
// OpenRouter lists its models without a key, so a reachable list proves nothing about the key:
|
|
475
|
+
// its /key does, and says what the key may still spend. A key it rejects fails the test.
|
|
476
|
+
if (name === 'openai' && isOpenRouter(base)) {
|
|
477
|
+
if (!key) return { ok: false, message: 'no API key configured — OpenRouter lists models without one, but every call needs it' };
|
|
478
|
+
const info = await openRouterKeyInfo(base, key, { fetch: f });
|
|
479
|
+
if (!info) return { ok: false, message: 'authentication failed — OpenRouter did not accept the key (its /key check failed)' };
|
|
480
|
+
return { ok: true, ...(n !== undefined ? { models: n } : {}), openrouter: info, detail: formatOpenRouterKeyInfo(info) };
|
|
481
|
+
}
|
|
290
482
|
return { ok: true, ...(n !== undefined ? { models: n } : {}) };
|
|
291
483
|
} catch (err) {
|
|
292
484
|
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
|
}
|