lazyclaw 3.99.11 → 3.99.12
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/cli.mjs +422 -40
- package/package.json +1 -1
- package/providers/openai_compat.mjs +301 -0
- package/providers/registry.mjs +73 -0
- package/web/dashboard.html +386 -0
|
@@ -0,0 +1,301 @@
|
|
|
1
|
+
// Generic OpenAI-compatible Chat Completions streaming provider.
|
|
2
|
+
//
|
|
3
|
+
// Targets any endpoint that speaks the OpenAI v1 wire format:
|
|
4
|
+
// - NVIDIA NIM (https://integrate.api.nvidia.com/v1)
|
|
5
|
+
// - OpenRouter (https://openrouter.ai/api/v1)
|
|
6
|
+
// - Together AI (https://api.together.xyz/v1)
|
|
7
|
+
// - Groq (https://api.groq.com/openai/v1)
|
|
8
|
+
// - DeepInfra / Fireworks / Anyscale / Mistral La Plateforme / etc.
|
|
9
|
+
// - vLLM, LM Studio, llama.cpp, text-generation-inference local servers
|
|
10
|
+
//
|
|
11
|
+
// Built so the picker can register a new endpoint at setup time without
|
|
12
|
+
// shipping a per-vendor provider file. The factory returns a Provider
|
|
13
|
+
// object the registry can drop into PROVIDERS as-is.
|
|
14
|
+
//
|
|
15
|
+
// Wire format reference (matches openai.mjs almost line-for-line; the
|
|
16
|
+
// only knobs are the base URL, optional extra headers, and the api-key
|
|
17
|
+
// closure):
|
|
18
|
+
// POST <baseUrl>/chat/completions
|
|
19
|
+
// Authorization: Bearer <key>
|
|
20
|
+
// {"model": "...", "stream": true, "messages": [...]}
|
|
21
|
+
|
|
22
|
+
const DEFAULT_MAX_TOKENS = 4096;
|
|
23
|
+
|
|
24
|
+
class InvalidApiKeyError extends Error {
|
|
25
|
+
constructor(message = 'invalid api key') {
|
|
26
|
+
super(message);
|
|
27
|
+
this.name = 'InvalidApiKeyError';
|
|
28
|
+
this.code = 'INVALID_KEY';
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
class AbortError extends Error {
|
|
33
|
+
constructor(message = 'aborted') {
|
|
34
|
+
super(message);
|
|
35
|
+
this.name = 'AbortError';
|
|
36
|
+
this.code = 'ABORT';
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
class RateLimitError extends Error {
|
|
41
|
+
constructor(retryAfterMs, body = '') {
|
|
42
|
+
super(`openai-compat 429: rate limited (retry-after ${retryAfterMs}ms)`);
|
|
43
|
+
this.name = 'RateLimitError';
|
|
44
|
+
this.code = 'RATE_LIMIT';
|
|
45
|
+
this.status = 429;
|
|
46
|
+
this.retryAfterMs = retryAfterMs;
|
|
47
|
+
this.body = body;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
class ApiError extends Error {
|
|
52
|
+
constructor(status, body) {
|
|
53
|
+
super(`openai-compat ${status}: ${String(body).slice(0, 200)}`);
|
|
54
|
+
this.name = 'OpenAiCompatApiError';
|
|
55
|
+
this.status = status;
|
|
56
|
+
this.body = body;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function parseRetryAfterMs(headers) {
|
|
61
|
+
let raw = null;
|
|
62
|
+
if (headers && typeof headers.get === 'function') raw = headers.get('retry-after') || headers.get('Retry-After');
|
|
63
|
+
else if (headers) raw = headers['retry-after'] || headers['Retry-After'];
|
|
64
|
+
if (!raw) return 1000;
|
|
65
|
+
const asInt = parseInt(String(raw), 10);
|
|
66
|
+
if (!Number.isNaN(asInt)) return Math.max(0, asInt * 1000);
|
|
67
|
+
const date = Date.parse(String(raw));
|
|
68
|
+
if (!Number.isNaN(date)) return Math.max(0, date - Date.now());
|
|
69
|
+
return 1000;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
async function* iterateBody(body) {
|
|
73
|
+
if (body && typeof body.getReader === 'function') {
|
|
74
|
+
const reader = body.getReader();
|
|
75
|
+
while (true) {
|
|
76
|
+
const { value, done } = await reader.read();
|
|
77
|
+
if (done) break;
|
|
78
|
+
if (value) yield value;
|
|
79
|
+
}
|
|
80
|
+
return;
|
|
81
|
+
}
|
|
82
|
+
if (body && typeof body[Symbol.asyncIterator] === 'function') {
|
|
83
|
+
for await (const chunk of body) yield chunk;
|
|
84
|
+
return;
|
|
85
|
+
}
|
|
86
|
+
if (typeof body === 'string') { yield new TextEncoder().encode(body); return; }
|
|
87
|
+
if (body instanceof Uint8Array) { yield body; return; }
|
|
88
|
+
throw new Error('openai-compat: response body is not iterable');
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function* parseSseFrames(buffer) {
|
|
92
|
+
let cursor = 0;
|
|
93
|
+
while (true) {
|
|
94
|
+
const sep = buffer.indexOf('\n\n', cursor);
|
|
95
|
+
if (sep < 0) break;
|
|
96
|
+
const frame = buffer.slice(cursor, sep);
|
|
97
|
+
cursor = sep + 2;
|
|
98
|
+
const dataLines = [];
|
|
99
|
+
for (const line of frame.split('\n')) {
|
|
100
|
+
if (line.startsWith('data:')) dataLines.push(line.slice(5).trim());
|
|
101
|
+
}
|
|
102
|
+
if (dataLines.length > 0) yield { data: dataLines.join('\n'), nextCursor: cursor };
|
|
103
|
+
else yield { data: '', nextCursor: cursor };
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// Normalise a base URL: strip trailing slashes so we can append `/chat/completions`
|
|
108
|
+
// or `/models` without worrying about doubled slashes. Accepts the user's input
|
|
109
|
+
// verbatim — we don't try to fix scheme or path quirks beyond the slash.
|
|
110
|
+
export function normaliseBaseUrl(raw) {
|
|
111
|
+
if (!raw) return '';
|
|
112
|
+
let s = String(raw).trim();
|
|
113
|
+
while (s.endsWith('/')) s = s.slice(0, -1);
|
|
114
|
+
return s;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Build a Provider object backed by an OpenAI-compatible HTTP endpoint.
|
|
119
|
+
* The returned object matches the shape registry.mjs expects:
|
|
120
|
+
* { name, sendMessage(messages, opts) -> AsyncIterable<string> }
|
|
121
|
+
*
|
|
122
|
+
* @param {Object} cfg
|
|
123
|
+
* @param {string} cfg.name Display name used in PROVIDERS / picker.
|
|
124
|
+
* @param {string} cfg.baseUrl e.g. https://integrate.api.nvidia.com/v1
|
|
125
|
+
* @param {string} [cfg.apiKey] Closure default. Caller can still override via opts.apiKey.
|
|
126
|
+
* @param {string} [cfg.defaultModel] Closure default model id.
|
|
127
|
+
* @param {Object<string,string>} [cfg.headers] Extra headers (e.g. {"x-foo": "bar"}).
|
|
128
|
+
*/
|
|
129
|
+
export function makeOpenAICompatProvider(cfg) {
|
|
130
|
+
const name = cfg.name;
|
|
131
|
+
const baseUrl = normaliseBaseUrl(cfg.baseUrl);
|
|
132
|
+
const closureKey = cfg.apiKey || '';
|
|
133
|
+
const closureModel = cfg.defaultModel || '';
|
|
134
|
+
const extraHeaders = cfg.headers || {};
|
|
135
|
+
if (!name) throw new Error('makeOpenAICompatProvider: name is required');
|
|
136
|
+
if (!baseUrl) throw new Error('makeOpenAICompatProvider: baseUrl is required');
|
|
137
|
+
|
|
138
|
+
return {
|
|
139
|
+
name,
|
|
140
|
+
async *sendMessage(messages, opts = {}) {
|
|
141
|
+
const apiKey = opts.apiKey || closureKey;
|
|
142
|
+
const fetchFn = opts.fetch || globalThis.fetch;
|
|
143
|
+
if (!fetchFn) throw new Error(`${name}: no fetch implementation available`);
|
|
144
|
+
|
|
145
|
+
const model = opts.model || closureModel;
|
|
146
|
+
if (!model) throw new Error(`${name}: missing model — set cfg.model or pass opts.model`);
|
|
147
|
+
|
|
148
|
+
const apiMessages = [];
|
|
149
|
+
const sys = opts.system || messages.find(m => m.role === 'system')?.content;
|
|
150
|
+
if (sys) apiMessages.push({ role: 'system', content: String(sys) });
|
|
151
|
+
for (const m of messages) {
|
|
152
|
+
if (m.role === 'user' || m.role === 'assistant') {
|
|
153
|
+
apiMessages.push({ role: m.role, content: String(m.content ?? '') });
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
const body = {
|
|
158
|
+
model,
|
|
159
|
+
max_tokens: opts.maxTokens || DEFAULT_MAX_TOKENS,
|
|
160
|
+
stream: true,
|
|
161
|
+
messages: apiMessages,
|
|
162
|
+
};
|
|
163
|
+
if (Array.isArray(opts.tools) && opts.tools.length > 0) {
|
|
164
|
+
body.tools = opts.tools;
|
|
165
|
+
if (opts.toolChoice) body.tool_choice = opts.toolChoice;
|
|
166
|
+
}
|
|
167
|
+
if (typeof opts.onUsage === 'function') {
|
|
168
|
+
body.stream_options = { include_usage: true };
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
const headers = {
|
|
172
|
+
'content-type': 'application/json',
|
|
173
|
+
...extraHeaders,
|
|
174
|
+
};
|
|
175
|
+
if (apiKey) headers['authorization'] = `Bearer ${apiKey}`;
|
|
176
|
+
|
|
177
|
+
if (opts.signal?.aborted) throw new AbortError('aborted before request');
|
|
178
|
+
const url = `${baseUrl}/chat/completions`;
|
|
179
|
+
const res = await fetchFn(url, {
|
|
180
|
+
method: 'POST',
|
|
181
|
+
headers,
|
|
182
|
+
body: JSON.stringify(body),
|
|
183
|
+
signal: opts.signal,
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
if (!res.ok) {
|
|
187
|
+
const text = typeof res.text === 'function' ? await res.text() : '';
|
|
188
|
+
if (res.status === 401 || res.status === 403) throw new InvalidApiKeyError(text || 'unauthorized');
|
|
189
|
+
if (res.status === 429) throw new RateLimitError(parseRetryAfterMs(res.headers), text || '');
|
|
190
|
+
throw new ApiError(res.status, text || '');
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
const decoder = new TextDecoder('utf-8', { fatal: false });
|
|
194
|
+
let buffer = '';
|
|
195
|
+
let usage = null;
|
|
196
|
+
const toolCallsByIndex = new Map();
|
|
197
|
+
const flushToolCall = (idx) => {
|
|
198
|
+
const tc = toolCallsByIndex.get(idx);
|
|
199
|
+
if (!tc || !tc.function?.name) return;
|
|
200
|
+
toolCallsByIndex.delete(idx);
|
|
201
|
+
if (typeof opts.onToolUse !== 'function') return;
|
|
202
|
+
let input = {};
|
|
203
|
+
try { input = tc.function.arguments ? JSON.parse(tc.function.arguments) : {}; }
|
|
204
|
+
catch { /* malformed → empty + raw */ }
|
|
205
|
+
try {
|
|
206
|
+
opts.onToolUse({
|
|
207
|
+
id: tc.id || null,
|
|
208
|
+
name: tc.function.name,
|
|
209
|
+
input,
|
|
210
|
+
raw: tc.function.arguments || '',
|
|
211
|
+
});
|
|
212
|
+
} catch { /* never let a callback abort the stream */ }
|
|
213
|
+
};
|
|
214
|
+
for await (const chunk of iterateBody(res.body)) {
|
|
215
|
+
if (opts.signal?.aborted) throw new AbortError('aborted mid-stream');
|
|
216
|
+
buffer += typeof chunk === 'string' ? chunk : decoder.decode(chunk, { stream: true });
|
|
217
|
+
let consumed = 0;
|
|
218
|
+
for (const frame of parseSseFrames(buffer)) {
|
|
219
|
+
consumed = frame.nextCursor;
|
|
220
|
+
if (!frame.data) continue;
|
|
221
|
+
if (frame.data === '[DONE]') {
|
|
222
|
+
for (const idx of Array.from(toolCallsByIndex.keys())) flushToolCall(idx);
|
|
223
|
+
if (usage && typeof opts.onUsage === 'function') {
|
|
224
|
+
try { opts.onUsage(usage); } catch { /* swallow */ }
|
|
225
|
+
}
|
|
226
|
+
return;
|
|
227
|
+
}
|
|
228
|
+
try {
|
|
229
|
+
const obj = JSON.parse(frame.data);
|
|
230
|
+
if (obj?.usage && typeof obj.usage === 'object') {
|
|
231
|
+
usage = {
|
|
232
|
+
inputTokens: obj.usage.prompt_tokens ?? null,
|
|
233
|
+
outputTokens: obj.usage.completion_tokens ?? null,
|
|
234
|
+
totalTokens: obj.usage.total_tokens ?? null,
|
|
235
|
+
};
|
|
236
|
+
}
|
|
237
|
+
const choice = obj?.choices?.[0];
|
|
238
|
+
const delta = choice?.delta || {};
|
|
239
|
+
if (delta.content) yield delta.content;
|
|
240
|
+
if (Array.isArray(delta.tool_calls)) {
|
|
241
|
+
for (const td of delta.tool_calls) {
|
|
242
|
+
const idx = td.index ?? 0;
|
|
243
|
+
const cur = toolCallsByIndex.get(idx) || { id: null, function: { name: '', arguments: '' } };
|
|
244
|
+
if (td.id) cur.id = td.id;
|
|
245
|
+
if (td.function?.name) cur.function.name = td.function.name;
|
|
246
|
+
if (typeof td.function?.arguments === 'string') cur.function.arguments += td.function.arguments;
|
|
247
|
+
toolCallsByIndex.set(idx, cur);
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
if (choice?.finish_reason === 'tool_calls') {
|
|
251
|
+
for (const idx of Array.from(toolCallsByIndex.keys())) flushToolCall(idx);
|
|
252
|
+
}
|
|
253
|
+
} catch {
|
|
254
|
+
// Ignore malformed frames; keep scanning the rest of the buffer.
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
if (consumed > 0) buffer = buffer.slice(consumed);
|
|
258
|
+
}
|
|
259
|
+
const tail = decoder.decode();
|
|
260
|
+
if (tail) buffer += tail;
|
|
261
|
+
},
|
|
262
|
+
};
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
/**
|
|
266
|
+
* Fetch the model catalogue from an OpenAI-compatible endpoint.
|
|
267
|
+
* Returns an array of model id strings, sorted alphabetically. Throws
|
|
268
|
+
* on transport errors so callers can surface the failure to the user.
|
|
269
|
+
*
|
|
270
|
+
* Endpoints we tested:
|
|
271
|
+
* - https://api.openai.com/v1/models → { data: [{id, ...}] }
|
|
272
|
+
* - https://integrate.api.nvidia.com/v1/models → { data: [{id, ...}] }
|
|
273
|
+
* - https://openrouter.ai/api/v1/models → { data: [{id, ...}] }
|
|
274
|
+
* - https://api.together.xyz/v1/models → [{id, ...}] (bare list)
|
|
275
|
+
* - http://127.0.0.1:11434/v1/models (Ollama) → { data: [{id, ...}] }
|
|
276
|
+
* - http://localhost:1234/v1/models (LM Studio) → { data: [{id, ...}] }
|
|
277
|
+
*/
|
|
278
|
+
export async function fetchOpenAICompatModels({ baseUrl, apiKey, headers, fetch: fetchOverride, signal } = {}) {
|
|
279
|
+
const fetchFn = fetchOverride || globalThis.fetch;
|
|
280
|
+
if (!fetchFn) throw new Error('fetchOpenAICompatModels: no fetch implementation available');
|
|
281
|
+
const url = `${normaliseBaseUrl(baseUrl)}/models`;
|
|
282
|
+
const h = { 'accept': 'application/json', ...(headers || {}) };
|
|
283
|
+
if (apiKey) h['authorization'] = `Bearer ${apiKey}`;
|
|
284
|
+
const res = await fetchFn(url, { method: 'GET', headers: h, signal });
|
|
285
|
+
if (!res.ok) {
|
|
286
|
+
const body = typeof res.text === 'function' ? await res.text().catch(() => '') : '';
|
|
287
|
+
if (res.status === 401 || res.status === 403) throw new InvalidApiKeyError(body || 'unauthorized');
|
|
288
|
+
throw new ApiError(res.status, body || '');
|
|
289
|
+
}
|
|
290
|
+
const obj = typeof res.json === 'function' ? await res.json() : JSON.parse(typeof res.text === 'function' ? await res.text() : '{}');
|
|
291
|
+
const list = Array.isArray(obj) ? obj : (Array.isArray(obj?.data) ? obj.data : []);
|
|
292
|
+
const ids = [];
|
|
293
|
+
for (const item of list) {
|
|
294
|
+
if (typeof item === 'string') ids.push(item);
|
|
295
|
+
else if (item && typeof item.id === 'string') ids.push(item.id);
|
|
296
|
+
else if (item && typeof item.name === 'string') ids.push(item.name);
|
|
297
|
+
}
|
|
298
|
+
return Array.from(new Set(ids)).sort((a, b) => a.localeCompare(b));
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
export { InvalidApiKeyError, ApiError, AbortError, RateLimitError };
|
package/providers/registry.mjs
CHANGED
|
@@ -12,6 +12,7 @@ import { openaiProvider } from './openai.mjs';
|
|
|
12
12
|
import { ollamaProvider } from './ollama.mjs';
|
|
13
13
|
import { geminiProvider } from './gemini.mjs';
|
|
14
14
|
import { claudeCliProvider } from './claude_cli.mjs';
|
|
15
|
+
import { makeOpenAICompatProvider, fetchOpenAICompatModels } from './openai_compat.mjs';
|
|
15
16
|
|
|
16
17
|
/**
|
|
17
18
|
* @typedef {{ role: 'user'|'assistant'|'system', content: string }} ChatMessage
|
|
@@ -49,6 +50,7 @@ export const mockProvider = {
|
|
|
49
50
|
};
|
|
50
51
|
|
|
51
52
|
export { anthropicProvider, openaiProvider, ollamaProvider, geminiProvider, claudeCliProvider };
|
|
53
|
+
export { makeOpenAICompatProvider, fetchOpenAICompatModels };
|
|
52
54
|
|
|
53
55
|
// Insertion order is the picker order. The list goes first-to-last in
|
|
54
56
|
// rough "user-familiar / popular" order so a first-time onboard lands
|
|
@@ -192,6 +194,77 @@ export function parseProviderModel(s) {
|
|
|
192
194
|
* @param {string|undefined|null} key
|
|
193
195
|
* @returns {string}
|
|
194
196
|
*/
|
|
197
|
+
// Reserved provider names — built-in providers and meta-keywords the picker
|
|
198
|
+
// uses internally. Custom registrations must not collide with these.
|
|
199
|
+
const RESERVED_PROVIDER_NAMES = new Set([
|
|
200
|
+
'mock', 'claude-cli', 'anthropic', 'openai', 'gemini', 'ollama',
|
|
201
|
+
'__add_custom__', '__custom_model__', '__fetch_models__',
|
|
202
|
+
]);
|
|
203
|
+
|
|
204
|
+
/**
|
|
205
|
+
* Validate a custom provider name. Allowed: lowercase alnum + dash + dot.
|
|
206
|
+
* Returns the trimmed name on success; throws on collision / bad format.
|
|
207
|
+
*/
|
|
208
|
+
export function validateCustomProviderName(raw) {
|
|
209
|
+
const name = String(raw || '').trim().toLowerCase();
|
|
210
|
+
if (!name) throw new Error('custom provider name is required');
|
|
211
|
+
if (!/^[a-z0-9][a-z0-9._-]{0,31}$/.test(name)) {
|
|
212
|
+
throw new Error('custom provider name must match [a-z0-9][a-z0-9._-]{0,31}');
|
|
213
|
+
}
|
|
214
|
+
if (RESERVED_PROVIDER_NAMES.has(name)) {
|
|
215
|
+
throw new Error(`custom provider name "${name}" is reserved (built-in)`);
|
|
216
|
+
}
|
|
217
|
+
return name;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/**
|
|
221
|
+
* Merge user-defined OpenAI-compatible custom providers into PROVIDERS /
|
|
222
|
+
* PROVIDER_INFO. Idempotent — safe to call multiple times; later calls
|
|
223
|
+
* overwrite earlier registrations of the same name. Returns the list of
|
|
224
|
+
* names that were added.
|
|
225
|
+
*
|
|
226
|
+
* Each entry shape (cfg.customProviders is an array):
|
|
227
|
+
* {
|
|
228
|
+
* name: 'nim',
|
|
229
|
+
* baseUrl: 'https://integrate.api.nvidia.com/v1',
|
|
230
|
+
* apiKey: 'nvapi-...', // optional — falls back to opts.apiKey
|
|
231
|
+
* defaultModel: 'meta/llama-3.1-70b', // optional
|
|
232
|
+
* suggestedModels: ['meta/...', ...], // optional — surfaced in the picker
|
|
233
|
+
* headers: { 'x-foo': 'bar' }, // optional — extra request headers
|
|
234
|
+
* docs: 'NVIDIA NIM hosted endpoint', // optional
|
|
235
|
+
* }
|
|
236
|
+
*/
|
|
237
|
+
export function registerCustomProviders(cfg) {
|
|
238
|
+
const list = Array.isArray(cfg?.customProviders) ? cfg.customProviders : [];
|
|
239
|
+
const added = [];
|
|
240
|
+
for (const entry of list) {
|
|
241
|
+
if (!entry || typeof entry !== 'object') continue;
|
|
242
|
+
let name;
|
|
243
|
+
try { name = validateCustomProviderName(entry.name); }
|
|
244
|
+
catch { continue; }
|
|
245
|
+
if (!entry.baseUrl) continue;
|
|
246
|
+
PROVIDERS[name] = makeOpenAICompatProvider({
|
|
247
|
+
name,
|
|
248
|
+
baseUrl: entry.baseUrl,
|
|
249
|
+
apiKey: entry.apiKey,
|
|
250
|
+
defaultModel: entry.defaultModel,
|
|
251
|
+
headers: entry.headers,
|
|
252
|
+
});
|
|
253
|
+
PROVIDER_INFO[name] = {
|
|
254
|
+
name,
|
|
255
|
+
requiresApiKey: !!entry.apiKey || entry.requiresApiKey !== false,
|
|
256
|
+
docs: entry.docs || `Custom OpenAI-compatible endpoint registered via setup. baseUrl=${entry.baseUrl}`,
|
|
257
|
+
endpoint: `${entry.baseUrl}/chat/completions`,
|
|
258
|
+
defaultModel: entry.defaultModel || null,
|
|
259
|
+
suggestedModels: Array.isArray(entry.suggestedModels) ? entry.suggestedModels.slice() : [],
|
|
260
|
+
custom: true,
|
|
261
|
+
baseUrl: entry.baseUrl,
|
|
262
|
+
};
|
|
263
|
+
added.push(name);
|
|
264
|
+
}
|
|
265
|
+
return added;
|
|
266
|
+
}
|
|
267
|
+
|
|
195
268
|
const KNOWN_KEY_PREFIXES = ['sk-ant-', 'sk-or-', 'sk-'];
|
|
196
269
|
export function maskApiKey(key) {
|
|
197
270
|
if (!key) return '';
|