otherwise-cli 0.1.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/README.md +193 -0
- package/bin/otherwise.js +5 -0
- package/frontend/404.html +84 -0
- package/frontend/assets/OpenDyslexic3-Bold-CDyRs55Y.ttf +0 -0
- package/frontend/assets/OpenDyslexic3-Regular-CIBXa4WE.ttf +0 -0
- package/frontend/assets/__vite-browser-external-BIHI7g3E.js +1 -0
- package/frontend/assets/conversational-worker-CeKiciGk.js +2929 -0
- package/frontend/assets/dictation-worker-D0aYfq8b.js +29 -0
- package/frontend/assets/gemini-color-CgSQmmva.png +0 -0
- package/frontend/assets/index-BLux5ps4.js +21 -0
- package/frontend/assets/index-Blh8_TEM.js +5272 -0
- package/frontend/assets/index-BpQ1PuKu.js +18 -0
- package/frontend/assets/index-Df737c8w.css +1 -0
- package/frontend/assets/index-xaYHL6wb.js +113 -0
- package/frontend/assets/ort-wasm-simd-threaded.asyncify-BynIiDiv.wasm +0 -0
- package/frontend/assets/ort-wasm-simd-threaded.jsep-B0T3yYHD.wasm +0 -0
- package/frontend/assets/transformers-tULNc5V3.js +31 -0
- package/frontend/assets/tts-worker-DPJWqT7N.js +2899 -0
- package/frontend/assets/voice-mode-worker-GzvIE_uh.js +2927 -0
- package/frontend/assets/worker-2d5ABSLU.js +31 -0
- package/frontend/banner.png +0 -0
- package/frontend/favicon.svg +3 -0
- package/frontend/google55e5ec47ee14a5f8.html +1 -0
- package/frontend/index.html +234 -0
- package/frontend/manifest.json +17 -0
- package/frontend/pdf.worker.min.mjs +21 -0
- package/frontend/robots.txt +5 -0
- package/frontend/sitemap.xml +27 -0
- package/package.json +81 -0
- package/src/agent/index.js +1066 -0
- package/src/agent/location.js +51 -0
- package/src/agent/prompt.js +548 -0
- package/src/agent/tools.js +4372 -0
- package/src/browser/detect.js +68 -0
- package/src/browser/session.js +1109 -0
- package/src/config.js +137 -0
- package/src/email/client.js +503 -0
- package/src/index.js +557 -0
- package/src/inference/anthropic.js +113 -0
- package/src/inference/google.js +373 -0
- package/src/inference/index.js +81 -0
- package/src/inference/ollama.js +383 -0
- package/src/inference/openai.js +140 -0
- package/src/inference/openrouter.js +378 -0
- package/src/inference/xai.js +200 -0
- package/src/logBridge.js +9 -0
- package/src/models.js +146 -0
- package/src/remote/client.js +225 -0
- package/src/scheduler/cron.js +243 -0
- package/src/server.js +3876 -0
- package/src/storage/db.js +1135 -0
- package/src/storage/supabase.js +364 -0
- package/src/tunnel/cloudflare.js +241 -0
- package/src/ui/components/App.jsx +687 -0
- package/src/ui/components/BrowserSelect.jsx +111 -0
- package/src/ui/components/FilePicker.jsx +472 -0
- package/src/ui/components/Header.jsx +444 -0
- package/src/ui/components/HelpPanel.jsx +173 -0
- package/src/ui/components/HistoryPanel.jsx +158 -0
- package/src/ui/components/MessageList.jsx +235 -0
- package/src/ui/components/ModelSelector.jsx +304 -0
- package/src/ui/components/PromptInput.jsx +515 -0
- package/src/ui/components/StreamingResponse.jsx +134 -0
- package/src/ui/components/ThinkingIndicator.jsx +365 -0
- package/src/ui/components/ToolExecution.jsx +714 -0
- package/src/ui/components/index.js +82 -0
- package/src/ui/context/TerminalContext.jsx +150 -0
- package/src/ui/context/index.js +13 -0
- package/src/ui/hooks/index.js +16 -0
- package/src/ui/hooks/useChatState.js +675 -0
- package/src/ui/hooks/useCommands.js +280 -0
- package/src/ui/hooks/useFileAttachments.js +216 -0
- package/src/ui/hooks/useKeyboardShortcuts.js +173 -0
- package/src/ui/hooks/useNotifications.js +185 -0
- package/src/ui/hooks/useTerminalSize.js +151 -0
- package/src/ui/hooks/useWebSocket.js +273 -0
- package/src/ui/index.js +94 -0
- package/src/ui/ink-runner.js +22 -0
- package/src/ui/utils/formatters.js +424 -0
- package/src/ui/utils/index.js +6 -0
- package/src/ui/utils/markdown.js +166 -0
|
@@ -0,0 +1,373 @@
|
|
|
1
|
+
import { GoogleGenAI } from '@google/genai';
|
|
2
|
+
|
|
3
|
+
// Retry configuration for transient errors
|
|
4
|
+
const RETRY_CONFIG = {
|
|
5
|
+
maxRetries: 3,
|
|
6
|
+
initialDelay: 1000,
|
|
7
|
+
maxDelay: 10000,
|
|
8
|
+
retryableErrors: ['RESOURCE_EXHAUSTED', 'UNAVAILABLE', 'DEADLINE_EXCEEDED', 'INTERNAL'],
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Check if a model is an image generation model
|
|
13
|
+
*/
|
|
14
|
+
export function isGeminiImageModel(model) {
|
|
15
|
+
return model.includes('-image') || model === 'gemini-3-pro-image-preview';
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Check if a model supports extended thinking/reasoning
|
|
20
|
+
* @param {string} model - Model identifier
|
|
21
|
+
* @returns {boolean} - Whether model supports thinking
|
|
22
|
+
*/
|
|
23
|
+
export function isGeminiReasoningModel(model) {
|
|
24
|
+
// Gemini 2.5 Pro/Flash and Gemini 3 Pro/Flash support thinking, BUT NOT:
|
|
25
|
+
// - gemini-2.5-flash-image (image generation model)
|
|
26
|
+
// - gemini-2.5-flash-lite (no reasoning support)
|
|
27
|
+
// - gemini-3-pro-image-preview (image generation model)
|
|
28
|
+
const isImageModel = model.includes('-image');
|
|
29
|
+
const isLiteModel = model.includes('-lite');
|
|
30
|
+
return !isImageModel && !isLiteModel && (
|
|
31
|
+
model.includes('2.5-pro') ||
|
|
32
|
+
model.includes('2.5-flash') ||
|
|
33
|
+
model.includes('3-pro') ||
|
|
34
|
+
model.includes('3-flash')
|
|
35
|
+
);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Sleep helper for retry delays
|
|
40
|
+
* @param {number} ms - Milliseconds to sleep
|
|
41
|
+
*/
|
|
42
|
+
function sleep(ms) {
|
|
43
|
+
return new Promise(resolve => setTimeout(resolve, ms));
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Check if an error is retryable
|
|
48
|
+
* @param {Error} error - The error to check
|
|
49
|
+
* @returns {boolean} - Whether error is retryable
|
|
50
|
+
*/
|
|
51
|
+
function isRetryableError(error) {
|
|
52
|
+
const message = error.message || '';
|
|
53
|
+
const code = error.code || error.status || '';
|
|
54
|
+
|
|
55
|
+
// Check for rate limiting
|
|
56
|
+
if (message.includes('429') || message.includes('rate limit') || message.includes('quota')) {
|
|
57
|
+
return true;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// Check for known retryable error codes
|
|
61
|
+
return RETRY_CONFIG.retryableErrors.some(e =>
|
|
62
|
+
message.includes(e) || String(code).includes(e)
|
|
63
|
+
);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Normalize image data to base64 string (SDK may return string or Uint8Array)
|
|
68
|
+
*/
|
|
69
|
+
function toBase64(data) {
|
|
70
|
+
if (typeof data === 'string') return data;
|
|
71
|
+
if (data instanceof Uint8Array) {
|
|
72
|
+
return Buffer.from(data).toString('base64');
|
|
73
|
+
}
|
|
74
|
+
if (data instanceof ArrayBuffer) {
|
|
75
|
+
return Buffer.from(new Uint8Array(data)).toString('base64');
|
|
76
|
+
}
|
|
77
|
+
if (Buffer.isBuffer(data)) return data.toString('base64');
|
|
78
|
+
return null;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Get response parts from various possible SDK response shapes
|
|
83
|
+
*/
|
|
84
|
+
function getPartsFromResponse(response) {
|
|
85
|
+
const c = response.candidates?.[0]?.content;
|
|
86
|
+
if (c?.parts) return c.parts;
|
|
87
|
+
if (Array.isArray(c)) return c;
|
|
88
|
+
const r = response.response?.candidates?.[0]?.content;
|
|
89
|
+
if (r?.parts) return r.parts;
|
|
90
|
+
if (Array.isArray(r)) return r;
|
|
91
|
+
return null;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Extract image blob from a part (camelCase or snake_case, part or nested)
|
|
96
|
+
*/
|
|
97
|
+
function getImageBlobFromPart(part) {
|
|
98
|
+
const blob = part.inlineData ?? part.inline_data ?? (part.data ? part : null);
|
|
99
|
+
if (!blob) return null;
|
|
100
|
+
const data = blob.data;
|
|
101
|
+
const base64 = toBase64(data);
|
|
102
|
+
if (!base64) return null;
|
|
103
|
+
const mimeType = blob.mimeType ?? blob.mime_type ?? part.mimeType ?? part.mime_type ?? 'image/png';
|
|
104
|
+
return { base64, mimeType };
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Generate an image using Gemini image models
|
|
109
|
+
* Uses responseModalities: ['TEXT', 'IMAGE'] to enable image output
|
|
110
|
+
* @param {string} model - Model identifier (e.g., 'gemini-2.5-flash-image')
|
|
111
|
+
* @param {string} prompt - Text prompt for image generation
|
|
112
|
+
* @param {object} config - Configuration with API keys and settings
|
|
113
|
+
* @yields {object} - Chunks with type and content (image as base64)
|
|
114
|
+
*/
|
|
115
|
+
export async function* generateGeminiImage(model, prompt, config) {
|
|
116
|
+
const apiKey = config.apiKeys?.google;
|
|
117
|
+
if (!apiKey) {
|
|
118
|
+
throw new Error('Google API key not configured. Run: otherwise config set google <key>');
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
const ai = new GoogleGenAI({ apiKey });
|
|
122
|
+
yield { type: 'start', model };
|
|
123
|
+
|
|
124
|
+
let lastError;
|
|
125
|
+
for (let attempt = 0; attempt < RETRY_CONFIG.maxRetries; attempt++) {
|
|
126
|
+
try {
|
|
127
|
+
const response = await ai.models.generateContent({
|
|
128
|
+
model,
|
|
129
|
+
contents: [{ role: 'user', parts: [{ text: prompt }] }],
|
|
130
|
+
config: {
|
|
131
|
+
response_modalities: ['TEXT', 'IMAGE'],
|
|
132
|
+
},
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
console.log('[Gemini image] Response top-level keys:', Object.keys(response || {}));
|
|
136
|
+
const parts = getPartsFromResponse(response);
|
|
137
|
+
if (!parts || !parts.length) {
|
|
138
|
+
console.warn('[Gemini image] No parts in response. Top-level keys:', Object.keys(response || {}));
|
|
139
|
+
if (response?.candidates?.[0]) {
|
|
140
|
+
console.warn('[Gemini image] candidates[0] keys:', Object.keys(response.candidates[0]));
|
|
141
|
+
if (response.candidates[0].content) {
|
|
142
|
+
console.warn('[Gemini image] content keys:', Object.keys(response.candidates[0].content));
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
throw new Error('No content returned from Gemini image generation');
|
|
146
|
+
}
|
|
147
|
+
console.log('[Gemini image] Parts count:', parts.length);
|
|
148
|
+
|
|
149
|
+
for (let i = 0; i < parts.length; i++) {
|
|
150
|
+
const part = parts[i];
|
|
151
|
+
const partKeys = Object.keys(part || {});
|
|
152
|
+
if (part.text) {
|
|
153
|
+
yield { type: 'text', content: part.text };
|
|
154
|
+
continue;
|
|
155
|
+
}
|
|
156
|
+
const blob = getImageBlobFromPart(part);
|
|
157
|
+
if (blob) {
|
|
158
|
+
console.log('[Gemini image] Yielding image chunk, base64 length:', blob.base64.length);
|
|
159
|
+
yield {
|
|
160
|
+
type: 'image',
|
|
161
|
+
content: blob.base64,
|
|
162
|
+
mimeType: blob.mimeType,
|
|
163
|
+
};
|
|
164
|
+
continue;
|
|
165
|
+
}
|
|
166
|
+
if (partKeys.length) {
|
|
167
|
+
console.warn('[Gemini image] Part', i, 'unknown shape, keys:', partKeys);
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
return;
|
|
171
|
+
} catch (error) {
|
|
172
|
+
lastError = error;
|
|
173
|
+
if (attempt < RETRY_CONFIG.maxRetries - 1 && isRetryableError(error)) {
|
|
174
|
+
const delay = Math.min(
|
|
175
|
+
RETRY_CONFIG.initialDelay * Math.pow(2, attempt),
|
|
176
|
+
RETRY_CONFIG.maxDelay
|
|
177
|
+
);
|
|
178
|
+
yield { type: 'warning', message: `Request failed, retrying in ${delay}ms...` };
|
|
179
|
+
await sleep(delay);
|
|
180
|
+
continue;
|
|
181
|
+
}
|
|
182
|
+
throw error;
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
throw lastError;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* Stream chat completion from Google Gemini
|
|
190
|
+
* @param {string} model - Model identifier (e.g., 'gemini-2.5-pro')
|
|
191
|
+
* @param {Array} messages - Array of message objects with role and content
|
|
192
|
+
* @param {string} systemPrompt - System prompt
|
|
193
|
+
* @param {object} config - Configuration with API keys and settings
|
|
194
|
+
* @yields {object} - Chunks with type and content
|
|
195
|
+
*/
|
|
196
|
+
export async function* streamGemini(model, messages, systemPrompt, config) {
|
|
197
|
+
const apiKey = config.apiKeys?.google;
|
|
198
|
+
if (!apiKey) {
|
|
199
|
+
throw new Error('Google API key not configured. Run: otherwise config set google <key>');
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
// Route image models to the image generation function
|
|
203
|
+
if (isGeminiImageModel(model)) {
|
|
204
|
+
// For image generation, use the last user message as the prompt
|
|
205
|
+
const lastUserMessage = [...messages].reverse().find(m => m.role === 'user');
|
|
206
|
+
const prompt = lastUserMessage?.content || 'Generate an image';
|
|
207
|
+
yield* generateGeminiImage(model, prompt, config);
|
|
208
|
+
return;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
const ai = new GoogleGenAI({ apiKey });
|
|
212
|
+
|
|
213
|
+
// Helper: parse data URL to base64 + mimeType for Gemini
|
|
214
|
+
const parseDataUrl = (dataUrl) => {
|
|
215
|
+
if (!dataUrl || !dataUrl.startsWith('data:')) return null;
|
|
216
|
+
const match = dataUrl.match(/^data:(image\/[^;]+);base64,(.+)$/);
|
|
217
|
+
if (!match) return null;
|
|
218
|
+
return { mimeType: match[1], data: match[2] };
|
|
219
|
+
};
|
|
220
|
+
|
|
221
|
+
// Convert messages to Gemini format (vision: user messages can have images)
|
|
222
|
+
const contents = messages.map(m => {
|
|
223
|
+
const role = m.role === 'assistant' ? 'model' : 'user';
|
|
224
|
+
if (m.images && m.images.length > 0 && m.role === 'user') {
|
|
225
|
+
const parts = [];
|
|
226
|
+
for (const imgDataUrl of m.images) {
|
|
227
|
+
const parsed = parseDataUrl(imgDataUrl);
|
|
228
|
+
if (parsed) {
|
|
229
|
+
parts.push({
|
|
230
|
+
inlineData: { mimeType: parsed.mimeType, data: parsed.data },
|
|
231
|
+
});
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
parts.push({ text: m.content || '' });
|
|
235
|
+
return { role, parts };
|
|
236
|
+
}
|
|
237
|
+
return { role, parts: [{ text: m.content }] };
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
// Check if this is a reasoning model that supports thoughts
|
|
241
|
+
const isReasoningModel = isGeminiReasoningModel(model);
|
|
242
|
+
|
|
243
|
+
const apiConfig = {
|
|
244
|
+
systemInstruction: systemPrompt,
|
|
245
|
+
temperature: config.temperature || 0.7,
|
|
246
|
+
maxOutputTokens: config.maxTokens || 8192,
|
|
247
|
+
...(isReasoningModel && {
|
|
248
|
+
thinkingConfig: {
|
|
249
|
+
includeThoughts: true,
|
|
250
|
+
},
|
|
251
|
+
}),
|
|
252
|
+
};
|
|
253
|
+
|
|
254
|
+
// Signal that streaming is starting
|
|
255
|
+
yield { type: 'start', model, isReasoningModel };
|
|
256
|
+
|
|
257
|
+
let lastError;
|
|
258
|
+
for (let attempt = 0; attempt < RETRY_CONFIG.maxRetries; attempt++) {
|
|
259
|
+
try {
|
|
260
|
+
const response = await ai.models.generateContentStream({
|
|
261
|
+
model,
|
|
262
|
+
contents,
|
|
263
|
+
config: apiConfig,
|
|
264
|
+
});
|
|
265
|
+
|
|
266
|
+
let totalThinkingChars = 0;
|
|
267
|
+
let usageMetadata = null;
|
|
268
|
+
let finishReason = null;
|
|
269
|
+
|
|
270
|
+
// Text buffering for smoother output
|
|
271
|
+
let textBuffer = '';
|
|
272
|
+
let lastFlush = Date.now();
|
|
273
|
+
const FLUSH_INTERVAL = 50; // ms - flush buffer every 50ms or on newlines
|
|
274
|
+
const MIN_BUFFER_SIZE = 3; // Minimum chars before considering flush
|
|
275
|
+
|
|
276
|
+
for await (const chunk of response) {
|
|
277
|
+
if (chunk.candidates?.[0]?.content?.parts) {
|
|
278
|
+
for (const part of chunk.candidates[0].content.parts) {
|
|
279
|
+
if (part.text) {
|
|
280
|
+
if (part.thought && isReasoningModel) {
|
|
281
|
+
// This is thinking/reasoning content - stream immediately for responsiveness
|
|
282
|
+
totalThinkingChars += part.text.length;
|
|
283
|
+
yield { type: 'thinking', content: part.text, totalChars: totalThinkingChars };
|
|
284
|
+
} else {
|
|
285
|
+
// Regular response content - buffer for smoother output
|
|
286
|
+
textBuffer += part.text;
|
|
287
|
+
const now = Date.now();
|
|
288
|
+
const timeSinceFlush = now - lastFlush;
|
|
289
|
+
|
|
290
|
+
// Flush buffer if:
|
|
291
|
+
// 1. Contains a newline (natural break point)
|
|
292
|
+
// 2. Enough time has passed and buffer has content
|
|
293
|
+
// 3. Buffer is getting large
|
|
294
|
+
const shouldFlush =
|
|
295
|
+
textBuffer.includes('\n') ||
|
|
296
|
+
(timeSinceFlush >= FLUSH_INTERVAL && textBuffer.length >= MIN_BUFFER_SIZE) ||
|
|
297
|
+
textBuffer.length > 100;
|
|
298
|
+
|
|
299
|
+
if (shouldFlush && textBuffer.length > 0) {
|
|
300
|
+
yield { type: 'text', content: textBuffer };
|
|
301
|
+
textBuffer = '';
|
|
302
|
+
lastFlush = now;
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
// Capture finish reason from candidates
|
|
310
|
+
if (chunk.candidates?.[0]?.finishReason) {
|
|
311
|
+
finishReason = chunk.candidates[0].finishReason;
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
// Capture usage metadata (available on chunks, last one has final counts)
|
|
315
|
+
if (chunk.usageMetadata) {
|
|
316
|
+
usageMetadata = chunk.usageMetadata;
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
// Flush any remaining buffered text
|
|
321
|
+
if (textBuffer.length > 0) {
|
|
322
|
+
yield { type: 'text', content: textBuffer };
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
// Yield usage stats at the end
|
|
326
|
+
if (usageMetadata) {
|
|
327
|
+
yield {
|
|
328
|
+
type: 'usage',
|
|
329
|
+
inputTokens: usageMetadata.promptTokenCount || 0,
|
|
330
|
+
outputTokens: usageMetadata.candidatesTokenCount || 0,
|
|
331
|
+
totalTokens: usageMetadata.totalTokenCount || 0,
|
|
332
|
+
thinkingTokens: usageMetadata.thoughtsTokenCount || 0,
|
|
333
|
+
finishReason: finishReason || 'STOP',
|
|
334
|
+
model,
|
|
335
|
+
};
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
return; // Success - exit retry loop
|
|
339
|
+
|
|
340
|
+
} catch (error) {
|
|
341
|
+
lastError = error;
|
|
342
|
+
|
|
343
|
+
// Check if we should retry
|
|
344
|
+
if (attempt < RETRY_CONFIG.maxRetries - 1 && isRetryableError(error)) {
|
|
345
|
+
const delay = Math.min(
|
|
346
|
+
RETRY_CONFIG.initialDelay * Math.pow(2, attempt),
|
|
347
|
+
RETRY_CONFIG.maxDelay
|
|
348
|
+
);
|
|
349
|
+
yield { type: 'warning', message: `Request failed, retrying in ${delay}ms...` };
|
|
350
|
+
await sleep(delay);
|
|
351
|
+
continue;
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
// Enhance error message for common issues
|
|
355
|
+
let enhancedMessage = error.message;
|
|
356
|
+
if (error.message.includes('API key')) {
|
|
357
|
+
enhancedMessage = 'Invalid Google API key. Run: otherwise config set google <your-key>';
|
|
358
|
+
} else if (error.message.includes('quota') || error.message.includes('429')) {
|
|
359
|
+
enhancedMessage = 'Rate limit exceeded. Please wait a moment and try again.';
|
|
360
|
+
} else if (error.message.includes('not found') || error.message.includes('404')) {
|
|
361
|
+
enhancedMessage = `Model "${model}" not found. Check available models with: otherwise models`;
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
const enhancedError = new Error(enhancedMessage);
|
|
365
|
+
enhancedError.originalError = error;
|
|
366
|
+
throw enhancedError;
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
throw lastError;
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
export default { streamGemini, generateGeminiImage, isGeminiImageModel, isGeminiReasoningModel };
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import { streamClaude } from './anthropic.js';
|
|
2
|
+
import { streamOpenAI, isOpenAIImageModel } from './openai.js';
|
|
3
|
+
import { streamGemini, isGeminiImageModel } from './google.js';
|
|
4
|
+
import { streamGrok, isGrokImageModel } from './xai.js';
|
|
5
|
+
import { streamOllama } from './ollama.js';
|
|
6
|
+
import { streamOpenRouter, isOpenRouterImageModel, fetchOpenRouterModels } from './openrouter.js';
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Check if a model is an image generation model
|
|
10
|
+
* Image models require special handling (different endpoints, no streaming, etc.)
|
|
11
|
+
*/
|
|
12
|
+
export function isImageModel(model) {
|
|
13
|
+
if (model.startsWith('gpt') || model.startsWith('dall-e')) {
|
|
14
|
+
return isOpenAIImageModel(model);
|
|
15
|
+
}
|
|
16
|
+
if (model.startsWith('gemini')) {
|
|
17
|
+
return isGeminiImageModel(model);
|
|
18
|
+
}
|
|
19
|
+
if (model.startsWith('grok')) {
|
|
20
|
+
return isGrokImageModel(model);
|
|
21
|
+
}
|
|
22
|
+
if (model.startsWith('openrouter:')) {
|
|
23
|
+
return isOpenRouterImageModel(model);
|
|
24
|
+
}
|
|
25
|
+
return false;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Route to the appropriate inference provider based on model name
|
|
30
|
+
* @param {string} model - Model identifier
|
|
31
|
+
* @param {Array} messages - Conversation messages
|
|
32
|
+
* @param {string} systemPrompt - System prompt
|
|
33
|
+
* @param {object} config - Configuration with API keys
|
|
34
|
+
* @returns {AsyncGenerator} - Yields chunks of response
|
|
35
|
+
*/
|
|
36
|
+
export async function* streamInference(model, messages, systemPrompt, config) {
|
|
37
|
+
// Determine provider from model name
|
|
38
|
+
if (model.startsWith('openrouter:')) {
|
|
39
|
+
yield* streamOpenRouter(model, messages, systemPrompt, config);
|
|
40
|
+
} else if (model.startsWith('claude')) {
|
|
41
|
+
yield* streamClaude(model, messages, systemPrompt, config);
|
|
42
|
+
} else if (model.startsWith('gpt') || model.startsWith('o1') || model.startsWith('o3') || model.startsWith('dall-e')) {
|
|
43
|
+
yield* streamOpenAI(model, messages, systemPrompt, config);
|
|
44
|
+
} else if (model.startsWith('gemini')) {
|
|
45
|
+
yield* streamGemini(model, messages, systemPrompt, config);
|
|
46
|
+
} else if (model.startsWith('grok')) {
|
|
47
|
+
yield* streamGrok(model, messages, systemPrompt, config);
|
|
48
|
+
} else if (model.startsWith('ollama:')) {
|
|
49
|
+
yield* streamOllama(model.replace('ollama:', ''), messages, systemPrompt, config);
|
|
50
|
+
} else {
|
|
51
|
+
// Default to Claude if unknown
|
|
52
|
+
yield* streamClaude(model, messages, systemPrompt, config);
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Get provider name from model
|
|
58
|
+
*/
|
|
59
|
+
export function getProviderFromModel(model) {
|
|
60
|
+
if (model.startsWith('openrouter:')) return 'openrouter';
|
|
61
|
+
if (model.startsWith('claude')) return 'anthropic';
|
|
62
|
+
if (model.startsWith('gpt') || model.startsWith('o1') || model.startsWith('o3') || model.startsWith('dall-e')) return 'openai';
|
|
63
|
+
if (model.startsWith('gemini')) return 'google';
|
|
64
|
+
if (model.startsWith('grok')) return 'xai';
|
|
65
|
+
if (model.startsWith('ollama:')) return 'ollama';
|
|
66
|
+
return 'anthropic';
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Check if required API key is configured for a model
|
|
71
|
+
*/
|
|
72
|
+
export function hasRequiredApiKey(model, config) {
|
|
73
|
+
const provider = getProviderFromModel(model);
|
|
74
|
+
if (provider === 'ollama') return true; // Ollama doesn't need API key
|
|
75
|
+
return !!config.apiKeys?.[provider];
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// Re-export OpenRouter model fetching for use in server
|
|
79
|
+
export { fetchOpenRouterModels } from './openrouter.js';
|
|
80
|
+
|
|
81
|
+
export default { streamInference, getProviderFromModel, hasRequiredApiKey, isImageModel, fetchOpenRouterModels };
|