Skip to content

Commit aa42fe8

Browse files
fix: correct Cerebras (system as user-role, max_completion_tokens), Groq (real endpoint+array content, was invented), Vercel (real proxy endpoint+x-api-key auth, was invented) callers ported from ScreenCaptureApiClients.kt/ScreenCaptureVercelClient.kt; Cloudflare has no native implementation anywhere so it now fails honestly instead of guessing an endpoint
1 parent d07e2f6 commit aa42fe8

1 file changed

Lines changed: 279 additions & 2 deletions

File tree

index.html

Lines changed: 279 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1754,9 +1754,16 @@
17541754
await _callPuter(payload);
17551755
} else if (provider === 'MISTRAL') {
17561756
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);
17571765
} 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.
17601767
await _callOpenAiCompat(payload, provider);
17611768
}
17621769
} catch(e) {
@@ -2188,6 +2195,276 @@
21882195
}
21892196
}
21902197

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+
21912468
/* ── OPENAI-COMPATIBLE (Mistral, Cerebras, Groq, Vercel, Cloudflare, custom) ── */
21922469
async function _callOpenAiCompat(payload, provider) {
21932470
const provDef = PROVIDER_API[provider];

0 commit comments

Comments
 (0)