total-recall-brain 3.21.2 → 3.22.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/frontend/dist/assets/index-C7_Xpk27.css +1 -0
- package/frontend/dist/assets/{index-D0XeeXhG.js → index-DJjuZ3t3.js} +106 -106
- package/frontend/dist/index.html +2 -2
- package/frontend/src/App.spec.tsx +1 -1
- package/frontend/src/api/_base.ts +3 -3
- package/frontend/src/api/embeddings.ts +46 -0
- package/frontend/src/api/mesh.ts +47 -0
- package/frontend/src/api/network.spec.ts +9 -9
- package/frontend/src/api/network.ts +4 -4
- package/frontend/src/api/notifications.ts +5 -4
- package/frontend/src/api/webhooks.ts +1 -1
- package/frontend/src/components/BrainSelector.spec.tsx +4 -4
- package/frontend/src/components/BrainSelector.tsx +1 -1
- package/frontend/src/components/ContextualHelp.spec.tsx +14 -14
- package/frontend/src/components/ContextualHelp.tsx +3 -1
- package/frontend/src/components/DocumentTable.spec.tsx +1 -1
- package/frontend/src/components/EmbeddingProviderPanel.spec.tsx +127 -0
- package/frontend/src/components/EmbeddingProviderPanel.tsx +177 -0
- package/frontend/src/components/Graph3D.spec.tsx +4 -4
- package/frontend/src/components/Graph3D.tsx +3 -3
- package/frontend/src/components/MobilePairing.spec.tsx +1 -1
- package/frontend/src/components/MobilePairing.tsx +1 -1
- package/frontend/src/components/UsageChart.spec.tsx +4 -4
- package/frontend/src/components/UsageChart.tsx +1 -1
- package/frontend/src/components/VoiceInput.spec.tsx +6 -6
- package/frontend/src/components/VoiceInput.tsx +19 -4
- package/frontend/src/components/brand/BrandMark.spec.tsx +3 -4
- package/frontend/src/index.css +6 -0
- package/frontend/src/pages/AutomationsPage.spec.tsx +4 -4
- package/frontend/src/pages/ChatPage.spec.tsx +3 -3
- package/frontend/src/pages/CollabPage.spec.tsx +1 -2
- package/frontend/src/pages/DesignDocsPage.spec.tsx +1 -1
- package/frontend/src/pages/FilesPage.spec.tsx +2 -2
- package/frontend/src/pages/GraphPage.spec.tsx +1 -1
- package/frontend/src/pages/HealthPage.spec.tsx +2 -2
- package/frontend/src/pages/HelpPage.spec.tsx +5 -5
- package/frontend/src/pages/InboxPage.spec.tsx +4 -4
- package/frontend/src/pages/InstructionsPage.spec.tsx +5 -5
- package/frontend/src/pages/IntegrationsPage.spec.tsx +7 -7
- package/frontend/src/pages/MemoryPage.spec.tsx +2 -2
- package/frontend/src/pages/MeshPage.spec.tsx +111 -2
- package/frontend/src/pages/MeshPage.tsx +95 -11
- package/frontend/src/pages/NetworkPage.spec.tsx +7 -8
- package/frontend/src/pages/NetworkPage.tsx +12 -12
- package/frontend/src/pages/NotificationsPage.tsx +9 -9
- package/frontend/src/pages/OnboardingPage.spec.tsx +7 -7
- package/frontend/src/pages/OpenWikiPage.tsx +3 -3
- package/frontend/src/pages/RulesPage.tsx +3 -3
- package/frontend/src/pages/SecretsPage.spec.tsx +5 -5
- package/frontend/src/pages/SecretsPage.tsx +13 -14
- package/frontend/src/pages/SettingsPage.spec.tsx +6 -6
- package/frontend/src/pages/SettingsPage.tsx +2 -2
- package/frontend/src/pages/SkillsPage.spec.tsx +1 -2
- package/frontend/src/pages/SkillsPage.tsx +3 -3
- package/frontend/src/pages/TasksPage.spec.tsx +3 -3
- package/frontend/src/pages/UsagePage.spec.tsx +2 -2
- package/frontend/src/pages/UsagePage.tsx +1 -1
- package/frontend/src/pages/WebhooksPage.spec.tsx +1 -1
- package/frontend/src/pages/WebhooksPage.tsx +19 -19
- package/frontend/src/setupTests.ts +4 -4
- package/package.json +1 -1
- package/scaffold/.agent/skills/total-recall/SKILL.md +92 -0
- package/scaffold/.agent/skills/total-recall/modules/agents/agents.yml +15 -13
- package/scaffold/.agent/skills/total-recall/research-queue.jsonl +0 -1
- package/src/cli/secret.mjs +93 -12
- package/src/core/browser-session.mjs +185 -0
- package/src/core/daemon-loop.mjs +28 -1
- package/src/core/embeddings.mjs +52 -10
- package/src/core/headscale-client.mjs +227 -0
- package/src/core/mesh-enroll.mjs +439 -0
- package/src/core/ollama-embeddings.mjs +336 -0
- package/src/core/provider-catalog.mjs +175 -6
- package/src/core/provider-rotation-recipes.mjs +299 -0
- package/src/core/rotation-capability.mjs +189 -0
- package/src/core/secrets-rotate.mjs +192 -0
- package/src/server/rest.mjs +2 -0
- package/src/server/route-manifest.json +20 -0
- package/src/server/routes/embeddings.mjs +111 -0
- package/src/server/routes/headscale.mjs +21 -81
- package/src/server/routes/mesh.mjs +41 -0
- package/frontend/dist/assets/index-B4RASWbV.css +0 -1
|
@@ -0,0 +1,336 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* src/core/ollama-embeddings.mjs
|
|
3
|
+
*
|
|
4
|
+
* Local / mesh Ollama embedding provider.
|
|
5
|
+
*
|
|
6
|
+
* Two properties here are load-bearing:
|
|
7
|
+
*
|
|
8
|
+
* 1. No hardcoded host. The endpoint comes from explicit config, then the
|
|
9
|
+
* loopback default, then live mesh discovery. A machine name baked into
|
|
10
|
+
* product code would leak one operator's topology into every install.
|
|
11
|
+
*
|
|
12
|
+
* 2. No hardcoded model. The model is selected from what the server actually
|
|
13
|
+
* reports — filtered by its declared capability and matched against the
|
|
14
|
+
* vault's vector width. Pinning a name would break the day it is renamed,
|
|
15
|
+
* unpulled, or superseded by a better one.
|
|
16
|
+
*
|
|
17
|
+
* Calls go through plain fetch rather than throttledFetch: this is local
|
|
18
|
+
* infrastructure on the operator's own mesh, not a rate-limited third-party
|
|
19
|
+
* API, and it must keep working when the outbound network policy is strict.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
import { logger } from './logger.mjs';
|
|
23
|
+
|
|
24
|
+
const OLLAMA_DEFAULT_PORT = 11434;
|
|
25
|
+
const PROBE_TIMEOUT_MS = 2500;
|
|
26
|
+
const SHOW_TIMEOUT_MS = 5000;
|
|
27
|
+
const EMBED_TIMEOUT_MS = 30000;
|
|
28
|
+
const DISCOVERY_TTL_MS = 5 * 60_000;
|
|
29
|
+
|
|
30
|
+
/** fetch with a hard deadline. Manual controller — AbortSignal.timeout has bitten this file before. */
|
|
31
|
+
async function fetchWithDeadline(url, options = {}, timeoutMs = PROBE_TIMEOUT_MS) {
|
|
32
|
+
const controller = new AbortController();
|
|
33
|
+
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
34
|
+
try {
|
|
35
|
+
return await fetch(url, { ...options, signal: controller.signal });
|
|
36
|
+
} finally {
|
|
37
|
+
clearTimeout(timer);
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** Accepts `host`, `host:port`, or a full URL — OLLAMA_HOST is used all three ways in the wild. */
|
|
42
|
+
export function normalizeBaseUrl(value) {
|
|
43
|
+
const raw = String(value || '').trim();
|
|
44
|
+
if (!raw) return null;
|
|
45
|
+
const withScheme = /^https?:\/\//i.test(raw) ? raw : `http://${raw}`;
|
|
46
|
+
try {
|
|
47
|
+
const u = new URL(withScheme);
|
|
48
|
+
if (!u.port && !/:\d+$/.test(u.host)) u.port = String(OLLAMA_DEFAULT_PORT);
|
|
49
|
+
return u.origin;
|
|
50
|
+
} catch {
|
|
51
|
+
return null;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** Explicitly configured endpoints, highest precedence first. */
|
|
56
|
+
function configuredBaseUrls() {
|
|
57
|
+
return [process.env.TR_OLLAMA_URL, process.env.OLLAMA_HOST]
|
|
58
|
+
.map(normalizeBaseUrl)
|
|
59
|
+
.filter(Boolean);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Online mesh peers, as endpoint candidates. Discovery, not configuration —
|
|
64
|
+
* this is how a laptop finds the box that actually holds the models without
|
|
65
|
+
* anyone writing an address into the repo.
|
|
66
|
+
*/
|
|
67
|
+
async function meshCandidateUrls() {
|
|
68
|
+
try {
|
|
69
|
+
const { readTailscaleStatus } = await import('./mesh-enroll.mjs');
|
|
70
|
+
const status = readTailscaleStatus();
|
|
71
|
+
if (!status) return [];
|
|
72
|
+
const peers = Object.values(status.Peer || {});
|
|
73
|
+
return peers
|
|
74
|
+
.filter((p) => p?.Online)
|
|
75
|
+
.flatMap((p) => (p.TailscaleIPs || []).filter((ip) => !ip.includes(':')))
|
|
76
|
+
.map((ip) => normalizeBaseUrl(`${ip}:${OLLAMA_DEFAULT_PORT}`))
|
|
77
|
+
.filter(Boolean);
|
|
78
|
+
} catch {
|
|
79
|
+
return [];
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** @returns {Promise<Array<object>|null>} the server's model list, or null when it is not an Ollama endpoint. */
|
|
84
|
+
export async function listOllamaModels(baseUrl) {
|
|
85
|
+
try {
|
|
86
|
+
const res = await fetchWithDeadline(`${baseUrl}/api/tags`, {}, PROBE_TIMEOUT_MS);
|
|
87
|
+
if (!res.ok) return null;
|
|
88
|
+
const data = await res.json();
|
|
89
|
+
return Array.isArray(data?.models) ? data.models : null;
|
|
90
|
+
} catch {
|
|
91
|
+
return null;
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** @type {{url: string, at: number}|null} */
|
|
96
|
+
let endpointCache = null;
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Resolve a reachable Ollama endpoint: configured → loopback → mesh peers.
|
|
100
|
+
* @param {{force?: boolean}} [opts]
|
|
101
|
+
* @returns {Promise<string|null>}
|
|
102
|
+
*/
|
|
103
|
+
export async function resolveOllamaEndpoint({ force = false } = {}) {
|
|
104
|
+
if (!force && endpointCache && Date.now() - endpointCache.at < DISCOVERY_TTL_MS) {
|
|
105
|
+
return endpointCache.url;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
const configured = configuredBaseUrls();
|
|
109
|
+
const candidates = [
|
|
110
|
+
...configured,
|
|
111
|
+
normalizeBaseUrl(`127.0.0.1:${OLLAMA_DEFAULT_PORT}`),
|
|
112
|
+
// Mesh discovery is last and only consulted when nothing local answers, so
|
|
113
|
+
// the common case costs one loopback probe rather than a network sweep.
|
|
114
|
+
...(configured.length ? [] : await meshCandidateUrls()),
|
|
115
|
+
];
|
|
116
|
+
|
|
117
|
+
const seen = new Set();
|
|
118
|
+
for (const url of candidates) {
|
|
119
|
+
if (!url || seen.has(url)) continue;
|
|
120
|
+
seen.add(url);
|
|
121
|
+
const models = await listOllamaModels(url);
|
|
122
|
+
if (models) {
|
|
123
|
+
endpointCache = { url, at: Date.now() };
|
|
124
|
+
logger.debug('embeddings: resolved Ollama endpoint', { url, models: models.length });
|
|
125
|
+
return url;
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
endpointCache = null;
|
|
130
|
+
return null;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/** Full metadata for one model, including declared capabilities. */
|
|
134
|
+
async function describeModel(baseUrl, name) {
|
|
135
|
+
try {
|
|
136
|
+
const res = await fetchWithDeadline(
|
|
137
|
+
`${baseUrl}/api/show`,
|
|
138
|
+
{
|
|
139
|
+
method: 'POST',
|
|
140
|
+
headers: { 'Content-Type': 'application/json' },
|
|
141
|
+
body: JSON.stringify({ model: name }),
|
|
142
|
+
},
|
|
143
|
+
SHOW_TIMEOUT_MS,
|
|
144
|
+
);
|
|
145
|
+
if (!res.ok) return null;
|
|
146
|
+
return await res.json();
|
|
147
|
+
} catch {
|
|
148
|
+
return null;
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* The embedding width a model actually produces.
|
|
154
|
+
*
|
|
155
|
+
* Read the family-scoped key exactly. Generative models publish several
|
|
156
|
+
* *.embedding_length entries (a vision tower's width, an audio tower's width)
|
|
157
|
+
* and a loose regex will happily match one of those — picking a chat model
|
|
158
|
+
* that cannot embed at all.
|
|
159
|
+
*/
|
|
160
|
+
export function embeddingWidth(info) {
|
|
161
|
+
const family = info?.details?.family;
|
|
162
|
+
const modelInfo = info?.model_info || {};
|
|
163
|
+
if (family && Number.isFinite(modelInfo[`${family}.embedding_length`])) {
|
|
164
|
+
return modelInfo[`${family}.embedding_length`];
|
|
165
|
+
}
|
|
166
|
+
return null;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/** Ollama declares this explicitly; trust it over any name-based heuristic. */
|
|
170
|
+
export function canEmbed(info) {
|
|
171
|
+
return Array.isArray(info?.capabilities) && info.capabilities.includes('embedding');
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/** @type {Map<string, string|null>} */
|
|
175
|
+
const modelCache = new Map();
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* Choose an embedding model from what the server reports.
|
|
179
|
+
*
|
|
180
|
+
* @param {string} baseUrl
|
|
181
|
+
* @param {{dims: number, preferred?: string|null, force?: boolean}} opts
|
|
182
|
+
* `dims` is the vault's vector width — a model of any other width cannot be
|
|
183
|
+
* stored in the existing index, so width is a hard filter, not a preference.
|
|
184
|
+
* @returns {Promise<string|null>}
|
|
185
|
+
*/
|
|
186
|
+
export async function selectEmbeddingModel(baseUrl, { dims, preferred = null, force = false } = {}) {
|
|
187
|
+
const cacheKey = `${baseUrl}|${dims}|${preferred || ''}`;
|
|
188
|
+
if (!force && modelCache.has(cacheKey)) return modelCache.get(cacheKey);
|
|
189
|
+
|
|
190
|
+
const models = await listOllamaModels(baseUrl);
|
|
191
|
+
if (!models?.length) {
|
|
192
|
+
modelCache.set(cacheKey, null);
|
|
193
|
+
return null;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
const names = models.map((m) => m.name || m.model).filter(Boolean);
|
|
197
|
+
// An operator-pinned model still has to prove it can embed at the right
|
|
198
|
+
// width; a stale pin should fall through to discovery, not hard-fail.
|
|
199
|
+
const ordered = preferred
|
|
200
|
+
? [...names.filter((n) => n === preferred || n.split(':')[0] === preferred), ...names]
|
|
201
|
+
: names;
|
|
202
|
+
|
|
203
|
+
const bySize = new Map(models.map((m) => [m.name || m.model, m.size || Number.MAX_SAFE_INTEGER]));
|
|
204
|
+
const viable = [];
|
|
205
|
+
|
|
206
|
+
for (const name of [...new Set(ordered)]) {
|
|
207
|
+
const info = await describeModel(baseUrl, name);
|
|
208
|
+
if (!info || !canEmbed(info)) continue;
|
|
209
|
+
const width = embeddingWidth(info);
|
|
210
|
+
if (width !== dims) {
|
|
211
|
+
logger.debug('embeddings: skipping Ollama model on width mismatch', { name, width, want: dims });
|
|
212
|
+
continue;
|
|
213
|
+
}
|
|
214
|
+
if (preferred && (name === preferred || name.split(':')[0] === preferred)) {
|
|
215
|
+
modelCache.set(cacheKey, name);
|
|
216
|
+
return name;
|
|
217
|
+
}
|
|
218
|
+
viable.push(name);
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
// Smallest viable model wins: embedding models at equal width are near-equal
|
|
222
|
+
// in quality, and the small one keeps recall latency down.
|
|
223
|
+
viable.sort((a, b) => (bySize.get(a) || 0) - (bySize.get(b) || 0));
|
|
224
|
+
const chosen = viable[0] || null;
|
|
225
|
+
if (chosen) logger.debug('embeddings: selected Ollama model', { model: chosen, dims });
|
|
226
|
+
modelCache.set(cacheKey, chosen);
|
|
227
|
+
return chosen;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/**
|
|
231
|
+
* Embed one string. Returns the vector, or throws with a reason the caller can log.
|
|
232
|
+
*
|
|
233
|
+
* @param {string} text
|
|
234
|
+
* @param {{baseUrl: string, model: string, dims: number}} opts
|
|
235
|
+
* @returns {Promise<number[]>}
|
|
236
|
+
*/
|
|
237
|
+
export async function getOllamaEmbedding(text, { baseUrl, model, dims }) {
|
|
238
|
+
const res = await fetchWithDeadline(
|
|
239
|
+
`${baseUrl}/api/embeddings`,
|
|
240
|
+
{
|
|
241
|
+
method: 'POST',
|
|
242
|
+
headers: { 'Content-Type': 'application/json' },
|
|
243
|
+
body: JSON.stringify({ model, prompt: text }),
|
|
244
|
+
},
|
|
245
|
+
EMBED_TIMEOUT_MS,
|
|
246
|
+
);
|
|
247
|
+
|
|
248
|
+
if (!res.ok) {
|
|
249
|
+
const body = await res.text().catch(() => '');
|
|
250
|
+
throw new Error(`Ollama embedding error ${res.status}: ${body || 'no response body'}`);
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
const data = await res.json();
|
|
254
|
+
const embedding = data?.embedding;
|
|
255
|
+
if (!Array.isArray(embedding) || !embedding.length) {
|
|
256
|
+
throw new Error('Ollama returned no embedding vector');
|
|
257
|
+
}
|
|
258
|
+
// Storing a wrong-width vector corrupts the index silently; fail loudly instead.
|
|
259
|
+
if (Number.isFinite(dims) && embedding.length !== dims) {
|
|
260
|
+
throw new Error(`Ollama model ${model} returned ${embedding.length} dims, vault requires ${dims}`);
|
|
261
|
+
}
|
|
262
|
+
return embedding;
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
/**
|
|
266
|
+
* Every model the server holds, annotated for an operator choosing one:
|
|
267
|
+
* whether it can embed at all, how wide its vectors are, and whether that
|
|
268
|
+
* width fits the vault index. Models that cannot be used are still returned,
|
|
269
|
+
* with the reason visible, so the UI can explain the absence rather than
|
|
270
|
+
* silently showing an empty list.
|
|
271
|
+
*
|
|
272
|
+
* @param {string} baseUrl
|
|
273
|
+
* @param {{dims?: number}} [opts]
|
|
274
|
+
*/
|
|
275
|
+
export async function describeEmbeddingCandidates(baseUrl, { dims } = {}) {
|
|
276
|
+
const models = await listOllamaModels(baseUrl);
|
|
277
|
+
if (!models?.length) return [];
|
|
278
|
+
|
|
279
|
+
const out = [];
|
|
280
|
+
for (const m of models) {
|
|
281
|
+
const name = m.name || m.model;
|
|
282
|
+
if (!name) continue;
|
|
283
|
+
const info = await describeModel(baseUrl, name);
|
|
284
|
+
const embeds = info ? canEmbed(info) : false;
|
|
285
|
+
const width = info ? embeddingWidth(info) : null;
|
|
286
|
+
out.push({
|
|
287
|
+
name,
|
|
288
|
+
size: m.size ?? null,
|
|
289
|
+
family: info?.details?.family ?? null,
|
|
290
|
+
capabilities: info?.capabilities ?? [],
|
|
291
|
+
embedding: embeds,
|
|
292
|
+
dims: width,
|
|
293
|
+
compatible: Boolean(embeds && Number.isFinite(dims) && width === dims),
|
|
294
|
+
});
|
|
295
|
+
}
|
|
296
|
+
return out;
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
/**
|
|
300
|
+
* Current state of the local/mesh embedding provider, for display.
|
|
301
|
+
* Never throws — an unreachable endpoint is a reportable state, not an error.
|
|
302
|
+
*
|
|
303
|
+
* @param {{dims: number, preferred?: string|null}} opts
|
|
304
|
+
*/
|
|
305
|
+
export async function getOllamaProviderStatus({ dims, preferred = null } = {}) {
|
|
306
|
+
const endpoint = await resolveOllamaEndpoint();
|
|
307
|
+
if (!endpoint) {
|
|
308
|
+
return {
|
|
309
|
+
available: false,
|
|
310
|
+
endpoint: null,
|
|
311
|
+
selected: null,
|
|
312
|
+
preferred,
|
|
313
|
+
dims,
|
|
314
|
+
candidates: [],
|
|
315
|
+
reason: 'No Ollama endpoint answered on this host or any online mesh peer.',
|
|
316
|
+
};
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
const candidates = await describeEmbeddingCandidates(endpoint, { dims });
|
|
320
|
+
const selected = await selectEmbeddingModel(endpoint, { dims, preferred });
|
|
321
|
+
return {
|
|
322
|
+
available: Boolean(selected),
|
|
323
|
+
endpoint,
|
|
324
|
+
selected,
|
|
325
|
+
preferred,
|
|
326
|
+
dims,
|
|
327
|
+
candidates,
|
|
328
|
+
reason: selected ? null : `No model on ${endpoint} embeds at ${dims} dims.`,
|
|
329
|
+
};
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
/** Reset memoised discovery — used by tests and after a topology change. */
|
|
333
|
+
export function resetOllamaDiscovery() {
|
|
334
|
+
endpointCache = null;
|
|
335
|
+
modelCache.clear();
|
|
336
|
+
}
|
|
@@ -11,6 +11,11 @@
|
|
|
11
11
|
* console_url?: string,
|
|
12
12
|
* key_patterns: string[],
|
|
13
13
|
* schema: { auth: string, header?: string, env_keys: string[], notes?: string },
|
|
14
|
+
* rotation?: {
|
|
15
|
+
* class?: 'provider_api' | 'provider_browser' | 'self_generated' | 'manual',
|
|
16
|
+
* high_risk?: boolean,
|
|
17
|
+
* manual_keys?: string[],
|
|
18
|
+
* },
|
|
14
19
|
* default_monthly_cap_usd?: number | null,
|
|
15
20
|
* tiers?: { id: string, label: string, monthly_usd?: number | null }[],
|
|
16
21
|
* }} ProviderDef */
|
|
@@ -295,6 +300,124 @@ export const PROVIDER_CATALOG = [
|
|
|
295
300
|
default_monthly_cap_usd: 0,
|
|
296
301
|
tiers: [{ id: 'self-hosted', label: 'Self-hosted', monthly_usd: 0 }],
|
|
297
302
|
},
|
|
303
|
+
{
|
|
304
|
+
id: 'stripe',
|
|
305
|
+
name: 'Stripe',
|
|
306
|
+
docs_url: 'https://docs.stripe.com/api',
|
|
307
|
+
pricing_url: 'https://stripe.com/pricing',
|
|
308
|
+
console_url: 'https://dashboard.stripe.com/apikeys',
|
|
309
|
+
key_patterns: ['STRIPE_SECRET_KEY', 'STRIPE_API_KEY', 'STRIPE_PUBLISHABLE_KEY', 'STRIPE_WEBHOOK_SECRET'],
|
|
310
|
+
schema: {
|
|
311
|
+
auth: 'bearer',
|
|
312
|
+
header: 'Authorization: Bearer <key>',
|
|
313
|
+
env_keys: ['STRIPE_SECRET_KEY'],
|
|
314
|
+
notes: 'Live keys are payment-critical. Roll with a grace window; never revoke before export confirms.',
|
|
315
|
+
},
|
|
316
|
+
// Stripe exposes no API to mint or roll secret keys — dashboard only, by design.
|
|
317
|
+
rotation: { class: 'provider_browser', high_risk: true },
|
|
318
|
+
default_monthly_cap_usd: null,
|
|
319
|
+
tiers: [{ id: 'payg', label: 'Pay as you go', monthly_usd: null }],
|
|
320
|
+
},
|
|
321
|
+
{
|
|
322
|
+
id: 'vercel',
|
|
323
|
+
name: 'Vercel',
|
|
324
|
+
docs_url: 'https://vercel.com/docs/rest-api',
|
|
325
|
+
console_url: 'https://vercel.com/account/settings/tokens',
|
|
326
|
+
key_patterns: ['VERCEL_API_TOKEN', 'VERCEL_TOKEN'],
|
|
327
|
+
schema: { auth: 'bearer', header: 'Authorization: Bearer <token>', env_keys: ['VERCEL_API_TOKEN'] },
|
|
328
|
+
rotation: { class: 'provider_browser' },
|
|
329
|
+
default_monthly_cap_usd: 20,
|
|
330
|
+
tiers: [{ id: 'hobby', label: 'Hobby', monthly_usd: 0 }, { id: 'pro', label: 'Pro', monthly_usd: 20 }],
|
|
331
|
+
},
|
|
332
|
+
{
|
|
333
|
+
id: 'telnyx',
|
|
334
|
+
name: 'Telnyx',
|
|
335
|
+
docs_url: 'https://developers.telnyx.com/api',
|
|
336
|
+
console_url: 'https://portal.telnyx.com/#/app/api-keys',
|
|
337
|
+
key_patterns: ['TELNYX_API_KEY', 'TELNYX_SIP_PASSWORD'],
|
|
338
|
+
schema: { auth: 'bearer', header: 'Authorization: Bearer <key>', env_keys: ['TELNYX_API_KEY'] },
|
|
339
|
+
rotation: { class: 'provider_browser' },
|
|
340
|
+
default_monthly_cap_usd: null,
|
|
341
|
+
tiers: [{ id: 'payg', label: 'Pay as you go', monthly_usd: null }],
|
|
342
|
+
},
|
|
343
|
+
{
|
|
344
|
+
id: 'fal',
|
|
345
|
+
name: 'fal.ai',
|
|
346
|
+
docs_url: 'https://docs.fal.ai',
|
|
347
|
+
console_url: 'https://fal.ai/dashboard/keys',
|
|
348
|
+
key_patterns: ['FAL_KEY', 'FAL_AI_API_KEY'],
|
|
349
|
+
schema: { auth: 'header', header: 'Authorization: Key <key>', env_keys: ['FAL_KEY'] },
|
|
350
|
+
rotation: { class: 'provider_browser' },
|
|
351
|
+
default_monthly_cap_usd: 20,
|
|
352
|
+
tiers: [{ id: 'payg', label: 'Pay as you go', monthly_usd: null }],
|
|
353
|
+
},
|
|
354
|
+
{
|
|
355
|
+
id: 'tavily',
|
|
356
|
+
name: 'Tavily',
|
|
357
|
+
docs_url: 'https://docs.tavily.com',
|
|
358
|
+
console_url: 'https://app.tavily.com/home',
|
|
359
|
+
key_patterns: ['TAVILY_API_KEY'],
|
|
360
|
+
schema: { auth: 'bearer', header: 'Authorization: Bearer <key>', env_keys: ['TAVILY_API_KEY'] },
|
|
361
|
+
rotation: { class: 'provider_browser' },
|
|
362
|
+
default_monthly_cap_usd: 10,
|
|
363
|
+
tiers: [{ id: 'free', label: 'Free', monthly_usd: 0 }],
|
|
364
|
+
},
|
|
365
|
+
{
|
|
366
|
+
id: 'exa',
|
|
367
|
+
name: 'Exa',
|
|
368
|
+
docs_url: 'https://docs.exa.ai',
|
|
369
|
+
console_url: 'https://dashboard.exa.ai/api-keys',
|
|
370
|
+
key_patterns: ['EXA_API_KEY'],
|
|
371
|
+
schema: { auth: 'header', header: 'x-api-key: <key>', env_keys: ['EXA_API_KEY'] },
|
|
372
|
+
rotation: { class: 'provider_browser' },
|
|
373
|
+
default_monthly_cap_usd: 10,
|
|
374
|
+
tiers: [{ id: 'payg', label: 'Pay as you go', monthly_usd: null }],
|
|
375
|
+
},
|
|
376
|
+
{
|
|
377
|
+
id: 'npm',
|
|
378
|
+
name: 'npm',
|
|
379
|
+
docs_url: 'https://docs.npmjs.com/about-access-tokens',
|
|
380
|
+
console_url: 'https://www.npmjs.com/settings/~/tokens',
|
|
381
|
+
key_patterns: ['NPM_TOKEN', 'NPM_RECOVERY_CODE'],
|
|
382
|
+
schema: { auth: 'bearer', header: 'Authorization: Bearer <token>', env_keys: ['NPM_TOKEN'] },
|
|
383
|
+
// Recovery codes are single-issue artifacts, never machine-rotatable.
|
|
384
|
+
rotation: { class: 'provider_browser', manual_keys: ['NPM_RECOVERY_CODE'] },
|
|
385
|
+
default_monthly_cap_usd: 0,
|
|
386
|
+
tiers: [{ id: 'free', label: 'Free', monthly_usd: 0 }],
|
|
387
|
+
},
|
|
388
|
+
{
|
|
389
|
+
id: 'pexels',
|
|
390
|
+
name: 'Pexels',
|
|
391
|
+
docs_url: 'https://www.pexels.com/api/documentation/',
|
|
392
|
+
console_url: 'https://www.pexels.com/api/new/',
|
|
393
|
+
key_patterns: ['PEXELS_API_KEY'],
|
|
394
|
+
schema: { auth: 'header', header: 'Authorization: <key>', env_keys: ['PEXELS_API_KEY'] },
|
|
395
|
+
rotation: { class: 'provider_browser' },
|
|
396
|
+
default_monthly_cap_usd: 0,
|
|
397
|
+
tiers: [{ id: 'free', label: 'Free', monthly_usd: 0 }],
|
|
398
|
+
},
|
|
399
|
+
{
|
|
400
|
+
id: 'artificial-analysis',
|
|
401
|
+
name: 'Artificial Analysis',
|
|
402
|
+
docs_url: 'https://artificialanalysis.ai/documentation',
|
|
403
|
+
console_url: 'https://artificialanalysis.ai/account/api-keys',
|
|
404
|
+
key_patterns: ['ARTIFICIAL_ANALYSIS_API_KEY'],
|
|
405
|
+
schema: { auth: 'header', header: 'x-api-key: <key>', env_keys: ['ARTIFICIAL_ANALYSIS_API_KEY'] },
|
|
406
|
+
rotation: { class: 'provider_browser' },
|
|
407
|
+
default_monthly_cap_usd: 0,
|
|
408
|
+
tiers: [{ id: 'free', label: 'Free', monthly_usd: 0 }],
|
|
409
|
+
},
|
|
410
|
+
{
|
|
411
|
+
id: 'browser-use',
|
|
412
|
+
name: 'Browser Use',
|
|
413
|
+
docs_url: 'https://docs.browser-use.com',
|
|
414
|
+
console_url: 'https://cloud.browser-use.com/settings/api-keys',
|
|
415
|
+
key_patterns: ['BROWSER_USE_API_KEY'],
|
|
416
|
+
schema: { auth: 'bearer', header: 'Authorization: Bearer <key>', env_keys: ['BROWSER_USE_API_KEY'] },
|
|
417
|
+
rotation: { class: 'provider_browser' },
|
|
418
|
+
default_monthly_cap_usd: 10,
|
|
419
|
+
tiers: [{ id: 'payg', label: 'Pay as you go', monthly_usd: null }],
|
|
420
|
+
},
|
|
298
421
|
];
|
|
299
422
|
|
|
300
423
|
const byId = new Map(PROVIDER_CATALOG.map((p) => [p.id, p]));
|
|
@@ -310,24 +433,70 @@ export function getProvider(providerId) {
|
|
|
310
433
|
/**
|
|
311
434
|
* @param {string} keyName
|
|
312
435
|
*/
|
|
436
|
+
/**
|
|
437
|
+
* Minimum length for a derived stem before it may be used for a fuzzy match.
|
|
438
|
+
* Guards against short stems swallowing unrelated keys — e.g. the pattern
|
|
439
|
+
* `TR_TOKEN` reduces to the stem `TR`, which as a bare substring matches
|
|
440
|
+
* S-TR-IPE_SECRET_KEY and mis-attributes Stripe credentials to Total Recall.
|
|
441
|
+
*/
|
|
442
|
+
const MIN_FUZZY_STEM = 4;
|
|
443
|
+
|
|
444
|
+
/** Does `k` contain `stem` on an underscore token boundary (not mid-word)? */
|
|
445
|
+
function matchesOnTokenBoundary(k, stem) {
|
|
446
|
+
return (
|
|
447
|
+
k === stem ||
|
|
448
|
+
k.startsWith(stem + '_') ||
|
|
449
|
+
k.endsWith('_' + stem) ||
|
|
450
|
+
k.includes('_' + stem + '_')
|
|
451
|
+
);
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
/**
|
|
455
|
+
* Resolve the provider that owns a secret key name.
|
|
456
|
+
*
|
|
457
|
+
* Matching is tiered strongest→weakest so an exact pattern always wins over a
|
|
458
|
+
* fuzzy stem. Fuzzy tiers require a token boundary and a minimum stem length;
|
|
459
|
+
* bare substring matching is never used.
|
|
460
|
+
*
|
|
461
|
+
* @param {string} keyName
|
|
462
|
+
* @returns {ProviderDef|null}
|
|
463
|
+
*/
|
|
313
464
|
export function providerForKeyName(keyName) {
|
|
314
465
|
// Strip packaging prefixes so DEVELOPER_BRAVE_SEARCH_API_KEY → BRAVE_SEARCH_API_KEY
|
|
315
466
|
let k = String(keyName || '').toUpperCase();
|
|
316
467
|
k = k.replace(/^(DEVELOPER_|VITE_|NEXT_PUBLIC_|PUBLIC_|NUXT_PUBLIC_|PORTFOLIO_|ULTRACHAT_)+/g, '');
|
|
468
|
+
|
|
469
|
+
// Tier 1 — exact pattern match.
|
|
317
470
|
for (const p of PROVIDER_CATALOG) {
|
|
318
|
-
if (p.key_patterns.some((pat) =>
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
471
|
+
if (p.key_patterns.some((pat) => pat.toUpperCase() === k)) return p;
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
// Tier 2 — key is a suffixed variant of a full pattern (FOO_OPENAI_API_KEY).
|
|
475
|
+
for (const p of PROVIDER_CATALOG) {
|
|
476
|
+
if (p.key_patterns.some((pat) => k.endsWith('_' + pat.toUpperCase()))) return p;
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
// Tier 3 — pattern stem on a token boundary (GITHUB_TOKEN → GITHUB_APP_CLIENT_SECRET).
|
|
480
|
+
for (const p of PROVIDER_CATALOG) {
|
|
481
|
+
if (
|
|
482
|
+
p.key_patterns.some((pat) => {
|
|
483
|
+
const stem = pat.toUpperCase().replace(/_API_KEY$|_TOKEN$|_KEY$|_SECRET$/, '');
|
|
484
|
+
if (stem.length < MIN_FUZZY_STEM) return false;
|
|
485
|
+
return matchesOnTokenBoundary(k, stem);
|
|
486
|
+
})
|
|
487
|
+
) {
|
|
322
488
|
return p;
|
|
323
489
|
}
|
|
324
490
|
}
|
|
325
|
-
|
|
491
|
+
|
|
492
|
+
// Tier 4 — provider id on a token boundary.
|
|
326
493
|
const compact = k.replace(/-/g, '_');
|
|
327
494
|
for (const p of PROVIDER_CATALOG) {
|
|
328
495
|
const id = p.id.toUpperCase().replace(/-/g, '_');
|
|
329
|
-
if (
|
|
496
|
+
if (id.length < MIN_FUZZY_STEM) continue;
|
|
497
|
+
if (matchesOnTokenBoundary(compact, id)) return p;
|
|
330
498
|
}
|
|
499
|
+
|
|
331
500
|
return null;
|
|
332
501
|
}
|
|
333
502
|
|