|
1754 | 1754 | await _callPuter(payload); |
1755 | 1755 | } else if (provider === 'MISTRAL') { |
1756 | 1756 | await _callMistral(payload); |
| 1757 | + } else if (provider === 'CEREBRAS') { |
| 1758 | + await _callCerebras(payload); |
| 1759 | + } else if (provider === 'GROQ') { |
| 1760 | + await _callGroq(payload); |
| 1761 | + } else if (provider === 'VERCEL') { |
| 1762 | + await _callVercel(payload); |
| 1763 | + } else if (provider === 'CLOUDFLARE') { |
| 1764 | + await _callCloudflareUnsupported(payload); |
1757 | 1765 | } else { |
1758 | | - // Cerebras, Groq, Vercel, Cloudflare, and all custom models use a plain |
1759 | | - // (single-key, no coordinator) OpenAI-compatible chat completions endpoint. |
| 1766 | + // Only genuinely custom models (defined in custom-models.json) reach here. |
1760 | 1767 | await _callOpenAiCompat(payload, provider); |
1761 | 1768 | } |
1762 | 1769 | } catch(e) { |
|
2188 | 2195 | } |
2189 | 2196 | } |
2190 | 2197 |
|
| 2198 | + |
| 2199 | + |
| 2200 | +/* ── CEREBRAS API (dedicated - matches reasonWithCerebras() exactly) ── |
| 2201 | + Quirks that differ from a "plain" OpenAI-compatible call: |
| 2202 | + - System message + DB entries are each added as their own role:"user" message |
| 2203 | + (NOT role:"system" - this is exactly what the native code does). |
| 2204 | + - No image/screenshot support at all (GPT_OSS_120B has supportsScreenshot=false). |
| 2205 | + - Uses max_completion_tokens (not max_tokens), fixed at 1024. |
| 2206 | + - Simple single-retry-on-429 key switch (no full cooldown coordinator like Mistral). */ |
| 2207 | +async function _callCerebras(payload) { |
| 2208 | + const keys = _parseJsonArray(Bridge.getAllApiKeys('CEREBRAS')); |
| 2209 | + if (!keys.length) { |
| 2210 | + Bridge.onCustomModelError('Cerebras API key not found.'); |
| 2211 | + return; |
| 2212 | + } |
| 2213 | + let keyIdx = Bridge.getCurrentKeyIndex('CEREBRAS') || 0; |
| 2214 | + let apiKey = keys[keyIdx] || keys[0]; |
| 2215 | + |
| 2216 | + const apiMessages = []; |
| 2217 | + if (payload.systemMessage) apiMessages.push({ role: 'user', content: payload.systemMessage }); |
| 2218 | + if (payload.databaseEntries) apiMessages.push({ role: 'user', content: payload.databaseEntries }); |
| 2219 | + |
| 2220 | + const sanitizedHistory = _sanitizeHistoryForScreenElements(payload.history || []); |
| 2221 | + const mergedHistory = _mergeConsecutiveSameRole(sanitizedHistory); |
| 2222 | + mergedHistory.forEach(m => { |
| 2223 | + if (m && m.text) apiMessages.push({ role: m.role === 'user' ? 'user' : 'assistant', content: m.text }); |
| 2224 | + }); |
| 2225 | + if (payload.userText) apiMessages.push({ role: 'user', content: payload.userText }); |
| 2226 | + // Cerebras models here never supportsScreenshot=true, so images are intentionally never sent. |
| 2227 | + |
| 2228 | + const requestBody = { |
| 2229 | + model: payload.modelName, |
| 2230 | + messages: apiMessages, |
| 2231 | + max_completion_tokens: 1024, |
| 2232 | + temperature: payload.temperature || 0, |
| 2233 | + top_p: payload.topP || 0, |
| 2234 | + stream: true, |
| 2235 | + }; |
| 2236 | + |
| 2237 | + window.__customModelAbortController = new AbortController(); |
| 2238 | + let acc = ''; |
| 2239 | + try { |
| 2240 | + let response = await fetch('https://api.cerebras.ai/v1/chat/completions', { |
| 2241 | + method: 'POST', |
| 2242 | + headers: { 'Content-Type': 'application/json', 'Authorization': 'Bearer ' + apiKey }, |
| 2243 | + body: JSON.stringify(requestBody), |
| 2244 | + signal: window.__customModelAbortController.signal, |
| 2245 | + }); |
| 2246 | + |
| 2247 | + // Single retry with the next key on 429 (matches reasonWithCerebras exactly - not a full |
| 2248 | + // cooldown coordinator, just one switch-and-retry). |
| 2249 | + if (response.status === 429 && keys.length > 1) { |
| 2250 | + const nextIdx = (keyIdx + 1) % keys.length; |
| 2251 | + const nextKey = keys[nextIdx]; |
| 2252 | + if (nextKey && nextKey !== apiKey) { |
| 2253 | + Bridge.setCurrentKeyIndex(nextIdx, 'CEREBRAS'); |
| 2254 | + apiKey = nextKey; |
| 2255 | + response = await fetch('https://api.cerebras.ai/v1/chat/completions', { |
| 2256 | + method: 'POST', |
| 2257 | + headers: { 'Content-Type': 'application/json', 'Authorization': 'Bearer ' + apiKey }, |
| 2258 | + body: JSON.stringify(requestBody), |
| 2259 | + signal: window.__customModelAbortController.signal, |
| 2260 | + }); |
| 2261 | + } else { |
| 2262 | + throw new Error('Cerebras rate limit reached. Please add another API key.'); |
| 2263 | + } |
| 2264 | + } |
| 2265 | + |
| 2266 | + if (!response.ok) { |
| 2267 | + const errBody = await response.text(); |
| 2268 | + throw new Error('Unexpected code ' + response.status + ' - ' + errBody.slice(0, 400)); |
| 2269 | + } |
| 2270 | + |
| 2271 | + const reader = response.body.getReader(); |
| 2272 | + const decoder = new TextDecoder(); |
| 2273 | + let buf = ''; |
| 2274 | + while (true) { |
| 2275 | + const { done, value } = await reader.read(); |
| 2276 | + if (done) break; |
| 2277 | + buf += decoder.decode(value, { stream: true }); |
| 2278 | + const lines = buf.split('\n'); |
| 2279 | + buf = lines.pop(); |
| 2280 | + for (const line of lines) { |
| 2281 | + const t = line.trim(); |
| 2282 | + if (!t.startsWith('data:')) continue; |
| 2283 | + const data = t.slice(5).trim(); |
| 2284 | + if (!data || data === '[DONE]') continue; |
| 2285 | + try { |
| 2286 | + const json = JSON.parse(data); |
| 2287 | + const delta = json.choices?.[0]?.delta?.content || ''; |
| 2288 | + if (delta) { acc += delta; Bridge.onCustomModelPartialResponse(acc); } |
| 2289 | + } catch(e2) { /* ignore bad SSE chunk */ } |
| 2290 | + } |
| 2291 | + } |
| 2292 | + Bridge.onCustomModelFinalResponse(acc); |
| 2293 | + await _executeCommandsFromResponse(acc); |
| 2294 | + } catch(e) { |
| 2295 | + if (e && e.name === 'AbortError') { |
| 2296 | + Bridge.onCustomModelFinalResponse(acc + (acc ? '\n\n' : '') + '[stopped by user]'); |
| 2297 | + } else { |
| 2298 | + throw e; |
| 2299 | + } |
| 2300 | + } finally { |
| 2301 | + window.__customModelAbortController = null; |
| 2302 | + } |
| 2303 | +} |
| 2304 | + |
| 2305 | +/* ── GROQ API (dedicated - array-based content, matches callGroqApi in ScreenCaptureApiClients.kt) ── |
| 2306 | + Array-based message content ({type:"text"|"image_url"}), non-streaming, single-key |
| 2307 | + (no coordinator - native ScreenCaptureService path doesn't retry/rotate for Groq either). */ |
| 2308 | +async function _callGroq(payload) { |
| 2309 | + const keys = _parseJsonArray(Bridge.getAllApiKeys('GROQ')); |
| 2310 | + if (!keys.length) { |
| 2311 | + Bridge.onCustomModelError('No Groq API key configured. Please add one in the menu.'); |
| 2312 | + return; |
| 2313 | + } |
| 2314 | + const keyIdx = Bridge.getCurrentKeyIndex('GROQ') || 0; |
| 2315 | + const apiKey = keys[keyIdx] || keys[0]; |
| 2316 | + |
| 2317 | + const apiMessages = []; |
| 2318 | + const sysParts = []; |
| 2319 | + if (payload.systemMessage) sysParts.push(payload.systemMessage); |
| 2320 | + if (payload.databaseEntries) sysParts.push('Retrievable information:\n' + payload.databaseEntries); |
| 2321 | + if (sysParts.length) apiMessages.push({ role: 'system', content: [{ type: 'text', text: sysParts.join('\n\n') }] }); |
| 2322 | + |
| 2323 | + const sanitizedHistory = _sanitizeHistoryForScreenElements(payload.history || []); |
| 2324 | + const mergedHistory = _mergeConsecutiveSameRole(sanitizedHistory); |
| 2325 | + mergedHistory.forEach(m => { |
| 2326 | + if (m && m.text) apiMessages.push({ role: m.role === 'user' ? 'user' : 'assistant', content: [{ type: 'text', text: m.text }] }); |
| 2327 | + }); |
| 2328 | + |
| 2329 | + const userContent = []; |
| 2330 | + if (payload.userText) userContent.push({ type: 'text', text: payload.userText }); |
| 2331 | + (payload.images || []).forEach(uri => userContent.push({ type: 'image_url', image_url: { url: uri } })); |
| 2332 | + apiMessages.push({ role: 'user', content: userContent }); |
| 2333 | + |
| 2334 | + const requestBody = { model: payload.modelName, messages: apiMessages, stream: false }; |
| 2335 | + |
| 2336 | + window.__customModelAbortController = new AbortController(); |
| 2337 | + try { |
| 2338 | + const response = await fetch('https://api.groq.com/openai/v1/chat/completions', { |
| 2339 | + method: 'POST', |
| 2340 | + headers: { 'Content-Type': 'application/json', 'Authorization': 'Bearer ' + apiKey }, |
| 2341 | + body: JSON.stringify(requestBody), |
| 2342 | + signal: window.__customModelAbortController.signal, |
| 2343 | + }); |
| 2344 | + |
| 2345 | + const responseBodyString = await response.text(); |
| 2346 | + if (!response.ok) { |
| 2347 | + const errMsg = 'Groq Error ' + response.status + ': ' + responseBodyString.slice(0, 400); |
| 2348 | + if (response.status === 429 || response.status === 401 || _isQuotaExceededError(errMsg)) _rotateKey('GROQ'); |
| 2349 | + throw new Error(errMsg); |
| 2350 | + } |
| 2351 | + |
| 2352 | + let acc = ''; |
| 2353 | + try { |
| 2354 | + const json = JSON.parse(responseBodyString); |
| 2355 | + acc = json.choices?.[0]?.message?.content || ''; |
| 2356 | + } catch(e2) { |
| 2357 | + throw new Error('Groq: failed to parse response JSON'); |
| 2358 | + } |
| 2359 | + if (!acc) throw new Error('No response from model'); |
| 2360 | + |
| 2361 | + Bridge.onCustomModelPartialResponse(acc); |
| 2362 | + Bridge.onCustomModelFinalResponse(acc); |
| 2363 | + await _executeCommandsFromResponse(acc); |
| 2364 | + } catch(e) { |
| 2365 | + if (e && e.name === 'AbortError') { |
| 2366 | + Bridge.onCustomModelFinalResponse('[stopped by user]'); |
| 2367 | + } else { |
| 2368 | + throw e; |
| 2369 | + } |
| 2370 | + } finally { |
| 2371 | + window.__customModelAbortController = null; |
| 2372 | + } |
| 2373 | +} |
| 2374 | + |
| 2375 | +/* ── VERCEL API (dedicated - matches callVercelApi in ScreenCaptureVercelClient.kt) ── |
| 2376 | + NOTE: this is NOT the general Vercel AI Gateway - it's this project's own proxy at |
| 2377 | + v0-screen-operator-clon-pi.vercel.app, authenticated with an "x-api-key" header |
| 2378 | + (not "Authorization: Bearer"), plain string message content, streaming SSE. */ |
| 2379 | +async function _callVercel(payload) { |
| 2380 | + const keys = _parseJsonArray(Bridge.getAllApiKeys('VERCEL')); |
| 2381 | + if (!keys.length) { |
| 2382 | + Bridge.onCustomModelError('No Vercel API key configured. Please add one in the menu.'); |
| 2383 | + return; |
| 2384 | + } |
| 2385 | + const keyIdx = Bridge.getCurrentKeyIndex('VERCEL') || 0; |
| 2386 | + const apiKey = keys[keyIdx] || keys[0]; |
| 2387 | + |
| 2388 | + const messages = []; |
| 2389 | + const sysParts = []; |
| 2390 | + if (payload.systemMessage) sysParts.push(payload.systemMessage); |
| 2391 | + if (payload.databaseEntries) sysParts.push('Retrievable information:\n' + payload.databaseEntries); |
| 2392 | + if (sysParts.length) messages.push({ role: 'system', content: sysParts.join('\n\n') }); |
| 2393 | + |
| 2394 | + const sanitizedHistory = _sanitizeHistoryForScreenElements(payload.history || []); |
| 2395 | + const mergedHistory = _mergeConsecutiveSameRole(sanitizedHistory); |
| 2396 | + mergedHistory.forEach(m => { |
| 2397 | + if (m && m.text) messages.push({ role: m.role === 'user' ? 'user' : 'assistant', content: m.text }); |
| 2398 | + }); |
| 2399 | + if (payload.userText) messages.push({ role: 'user', content: payload.userText }); |
| 2400 | + // Note: the native Vercel client (ScreenCaptureVercelClient.kt) has no image support at all. |
| 2401 | + |
| 2402 | + const requestBody = { model: payload.modelName, messages, stream: true }; |
| 2403 | + |
| 2404 | + window.__customModelAbortController = new AbortController(); |
| 2405 | + let acc = ''; |
| 2406 | + try { |
| 2407 | + const response = await fetch('https://v0-screen-operator-clon-pi.vercel.app/api/chat', { |
| 2408 | + method: 'POST', |
| 2409 | + headers: { 'Content-Type': 'application/json', 'x-api-key': apiKey }, |
| 2410 | + body: JSON.stringify(requestBody), |
| 2411 | + signal: window.__customModelAbortController.signal, |
| 2412 | + }); |
| 2413 | + |
| 2414 | + if (!response.ok) { |
| 2415 | + let detail = ''; |
| 2416 | + try { detail = (await response.text()).slice(0, 400); } catch(e2) {} |
| 2417 | + throw new Error('Vercel API error ' + response.status + ': ' + detail); |
| 2418 | + } |
| 2419 | + |
| 2420 | + const reader = response.body.getReader(); |
| 2421 | + const decoder = new TextDecoder(); |
| 2422 | + let buf = ''; |
| 2423 | + while (true) { |
| 2424 | + const { done, value } = await reader.read(); |
| 2425 | + if (done) break; |
| 2426 | + buf += decoder.decode(value, { stream: true }); |
| 2427 | + const lines = buf.split('\n'); |
| 2428 | + buf = lines.pop(); |
| 2429 | + for (const line of lines) { |
| 2430 | + const t = line.trim(); |
| 2431 | + if (!t.startsWith('data:')) continue; |
| 2432 | + const data = t.slice(5).trim(); |
| 2433 | + if (!data || data === '[DONE]') continue; |
| 2434 | + try { |
| 2435 | + const json = JSON.parse(data); |
| 2436 | + const delta = json.choices?.[0]?.delta?.content || ''; |
| 2437 | + if (delta) { acc += delta; Bridge.onCustomModelPartialResponse(acc); } |
| 2438 | + } catch(e2) { /* ignore bad SSE chunk */ } |
| 2439 | + } |
| 2440 | + } |
| 2441 | + Bridge.onCustomModelFinalResponse(acc); |
| 2442 | + await _executeCommandsFromResponse(acc); |
| 2443 | + } catch(e) { |
| 2444 | + if (e && e.name === 'AbortError') { |
| 2445 | + Bridge.onCustomModelFinalResponse(acc + (acc ? '\n\n' : '') + '[stopped by user]'); |
| 2446 | + } else { |
| 2447 | + throw e; |
| 2448 | + } |
| 2449 | + } finally { |
| 2450 | + window.__customModelAbortController = null; |
| 2451 | + } |
| 2452 | +} |
| 2453 | + |
| 2454 | +/* ── CLOUDFLARE ──────────────────────────────────────────────── |
| 2455 | + IMPORTANT: unlike every other provider here, Cloudflare has NO API-client implementation |
| 2456 | + anywhere in the native codebase (checked PhotoReasoningViewModel.kt, ScreenCaptureService.kt, |
| 2457 | + ScreenCaptureApiClients.kt) even though CLOUDFLARE_KIMI_K2_6 exists as a ModelOption. Selecting |
| 2458 | + it natively would have sent the request through Google's Gemini SDK with an incompatible model |
| 2459 | + name/key and failed. Rather than inventing an unverified endpoint, this surfaces that clearly |
| 2460 | + instead of silently guessing at a request format that might send credentials somewhere unintended. */ |
| 2461 | +async function _callCloudflareUnsupported(payload) { |
| 2462 | + Bridge.onCustomModelError( |
| 2463 | + 'Cloudflare models are not yet implemented (no working API integration exists in this app for ' + |
| 2464 | + 'this provider). Please choose a different model.' |
| 2465 | + ); |
| 2466 | +} |
| 2467 | + |
2191 | 2468 | /* ── OPENAI-COMPATIBLE (Mistral, Cerebras, Groq, Vercel, Cloudflare, custom) ── */ |
2192 | 2469 | async function _callOpenAiCompat(payload, provider) { |
2193 | 2470 | const provDef = PROVIDER_API[provider]; |
|
0 commit comments