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.
@@ -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 };
@@ -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 '';