Files
gnome-jarvis/vendor/agent-harness/lib/groq.js
T
snxraven ac5f18f79d
Rolling release / release (push) Failing after 2m11s
Updates
2026-09-14 10:19:13 -04:00

454 lines
14 KiB
JavaScript

/**
* Groq Chat Completions for the agent loop. Host is pinned to api.groq.com.
* Tools still run locally; only chat tokens leave the machine.
*/
const fs = require('fs');
const https = require('https');
const path = require('path');
const completeWatch = require('./complete-watch.js');
const toolParse = require('./tool-parse.js');
const GROQ_HOST = 'api.groq.com';
const GROQ_PATH = '/openai/v1/chat/completions';
const MAX_IMAGE_BYTES = 4 * 1024 * 1024;
const VISION_MODELS = /^qwen\/qwen3\.[68]-27b$/i;
const NO_PARALLEL = /gpt-oss|qwen3\.8-27b/i;
let httpsRequest = https.request;
const activeRequests = new Set();
function setHttpsRequest(fn) {
httpsRequest = typeof fn === 'function' ? fn : https.request;
}
function groqHost() {
return GROQ_HOST;
}
function visionModel(model) {
return VISION_MODELS.test(String(model || ''));
}
function redact(text) {
return String(text || '')
.replace(/Bearer\s+\S+/gi, 'Bearer [redacted]')
.replace(/gsk_[A-Za-z0-9]+/g, 'gsk_[redacted]');
}
function fail(message) {
return new Error(redact(message));
}
function mimeForPath(file) {
const ext = path.extname(file).toLowerCase();
if (ext === '.png') return 'image/png';
if (ext === '.webp') return 'image/webp';
if (ext === '.gif') return 'image/gif';
return 'image/jpeg';
}
function imagePart(file) {
const buf = fs.readFileSync(file);
if (buf.length > MAX_IMAGE_BYTES) throw new Error('Groq vision still is too large');
return {
type: 'image_url',
image_url: { url: 'data:' + mimeForPath(file) + ';base64,' + buf.toString('base64') },
};
}
function toOpenAiTools(tools) {
if (!tools || !Array.isArray(tools) || !tools.length) return undefined;
const out = [];
for (const t of tools) {
const fn = t && t.function && t.type === 'function' ? t.function : t;
const name = fn && fn.name;
if (!name) continue;
const parameters = fn.parameters && fn.parameters.type === 'object'
? fn.parameters
: { type: 'object', properties: (fn.parameters && fn.parameters.properties) || {} };
out.push({
type: 'function',
function: {
name,
description: fn.description || name,
parameters,
},
});
}
return out.length ? out : undefined;
}
function contentWithImages(msg, vision) {
const text = msg.content == null ? '' : String(msg.content);
const attachments = Array.isArray(msg.attachments) ? msg.attachments : [];
if (!vision || !attachments.length) return text;
const parts = [];
if (text) parts.push({ type: 'text', text });
for (const att of attachments) {
const file = att && (att.path || att.file);
if (!file) continue;
try {
parts.push(imagePart(file));
} catch (err) {
parts.push({ type: 'text', text: '[image unavailable: ' + redact(err.message) + ']' });
}
}
return parts.length ? parts : text;
}
function mapToolCalls(calls) {
return (calls || []).map((c, i) => {
const name = c.name || (c.function && c.function.name);
const args = c.arguments != null ? c.arguments : c.args != null ? c.args : (c.function && c.function.arguments);
return {
id: c.id || 'call_' + i,
type: 'function',
function: {
name,
arguments: typeof args === 'string' ? args : JSON.stringify(args || {}),
},
};
}).filter((c) => c.function.name);
}
function reasoningEffort(model) {
if (/gpt-oss/i.test(model)) return 'low';
if (/qwen3\.[68]-27b/i.test(model)) return 'none';
return null;
}
function assertChatModel(model) {
if (/compound/i.test(model)) {
throw new Error('groq/compound runs tools on Groq. Pick a chat model so Jarvis tools stay on this computer.');
}
}
function toOpenAiMessages(history, { vision } = {}) {
const out = [];
for (const msg of Array.isArray(history) ? history : []) {
if (!msg || !msg.role) continue;
if (msg.role === 'tool' || msg.role === 'function') {
out.push({
role: 'tool',
tool_call_id: msg.tool_call_id || msg.toolCallId || '',
content: msg.content == null ? '' : typeof msg.content === 'string' ? msg.content : JSON.stringify(msg.content),
...(msg.name ? { name: msg.name } : {}),
});
continue;
}
if (msg.role === 'assistant' && Array.isArray(msg.tool_calls) && msg.tool_calls.length) {
out.push({
role: 'assistant',
content: msg.content ? String(msg.content) : null,
tool_calls: mapToolCalls(msg.tool_calls),
});
continue;
}
if (msg.role === 'system' || msg.role === 'user' || msg.role === 'assistant') {
out.push({ role: msg.role, content: contentWithImages(msg, vision) });
}
}
return out;
}
function parseArgs(raw) {
if (raw && typeof raw === 'object' && !Array.isArray(raw)) return raw;
if (typeof raw !== 'string') return raw == null ? {} : { value: raw };
const trimmed = raw.trim();
if (!trimmed) return {};
try {
const parsed = JSON.parse(trimmed);
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) return parsed;
return { value: parsed };
} catch (_) {
return { value: trimmed };
}
}
function harvestedCalls(buckets) {
const calls = [];
for (const c of buckets) {
if (!c || !c.name) continue;
calls.push({
id: c.id || '',
name: c.name,
arguments: parseArgs(c.arguments),
});
}
return calls;
}
function applyDelta(delta, state, onEvent) {
if (!delta || typeof delta !== 'object') return;
if (delta.content) {
state.text += delta.content;
if (onEvent) onEvent({ type: 'contentDelta', delta: delta.content });
}
const thinking = delta.reasoning || delta.reasoning_content;
if (thinking) {
state.thinking += thinking;
if (onEvent) onEvent({ type: 'thinkingDelta', delta: thinking });
}
if (!Array.isArray(delta.tool_calls)) return;
for (const part of delta.tool_calls) {
const i = Number.isInteger(part.index) ? part.index : state.toolCalls.length;
if (!state.toolCalls[i]) state.toolCalls[i] = { id: '', name: '', arguments: '' };
const slot = state.toolCalls[i];
if (part.id) slot.id = part.id;
const fn = part.function || {};
if (fn.name) slot.name += fn.name;
if (fn.arguments) slot.arguments += fn.arguments;
}
}
function consumeSse(chunk, carry, onEvent) {
let rest = carry + chunk;
let idx;
while ((idx = rest.indexOf('\n')) >= 0) {
let line = rest.slice(0, idx);
rest = rest.slice(idx + 1);
if (line.endsWith('\r')) line = line.slice(0, -1);
if (!line.startsWith('data:')) continue;
const data = line.slice(5).trim();
if (!data) continue;
if (data === '[DONE]') return { rest, done: true };
try {
onEvent(JSON.parse(data));
} catch (_) {}
}
return { rest, done: false };
}
function postChat(body, apiKey, { timeoutMs, abortHolder }) {
return new Promise((resolve, reject) => {
const payload = JSON.stringify(body);
let req;
req = httpsRequest({
hostname: GROQ_HOST,
path: GROQ_PATH,
method: 'POST',
headers: {
Authorization: 'Bearer ' + apiKey,
'Content-Type': 'application/json',
Accept: 'text/event-stream',
'Content-Length': Buffer.byteLength(payload),
},
}, (res) => {
const chunks = [];
res.on('data', (d) => chunks.push(d));
res.on('end', () => {
activeRequests.delete(req);
const raw = Buffer.concat(chunks.map((c) => Buffer.isBuffer(c) ? c : Buffer.from(c))).toString('utf8');
resolve({ status: res.statusCode || 0, raw, headers: res.headers || {} });
});
res.on('error', (err) => {
activeRequests.delete(req);
reject(fail(err.message));
});
});
abortHolder.req = req;
activeRequests.add(req);
req.on('error', (err) => {
activeRequests.delete(req);
reject(fail(err.message));
});
if (timeoutMs > 0 && typeof req.setTimeout === 'function') {
req.setTimeout(timeoutMs, () => {
try { req.destroy(fail('Groq request timed out')); } catch (_) {}
});
}
req.write(payload);
req.end();
});
}
function streamChat(body, apiKey, { onChunk, timeoutMs, abortHolder, watch }) {
return new Promise((resolve, reject) => {
const payload = JSON.stringify(body);
let req;
let settled = false;
const finish = (err, value) => {
if (settled) return;
settled = true;
activeRequests.delete(req);
if (err) reject(err);
else resolve(value);
};
req = httpsRequest({
hostname: GROQ_HOST,
path: GROQ_PATH,
method: 'POST',
headers: {
Authorization: 'Bearer ' + apiKey,
'Content-Type': 'application/json',
Accept: 'text/event-stream',
'Content-Length': Buffer.byteLength(payload),
},
}, (res) => {
if ((res.statusCode || 0) >= 400) {
const chunks = [];
res.on('data', (d) => chunks.push(d));
res.on('end', () => {
const raw = Buffer.concat(chunks.map((c) => Buffer.isBuffer(c) ? c : Buffer.from(c))).toString('utf8');
finish(fail('Groq HTTP ' + res.statusCode + ': ' + raw.slice(0, 400)));
});
res.on('error', (err) => finish(fail(err.message)));
return;
}
let carry = '';
res.on('data', (d) => {
if (watch && watch.timedOut()) {
try { req.destroy(); } catch (_) {}
return;
}
if (watch) watch.bump();
const text = Buffer.isBuffer(d) ? d.toString('utf8') : String(d);
const parsed = consumeSse(text, carry, onChunk);
carry = parsed.rest;
if (parsed.done) {
finish(null, true);
try { req.destroy(); } catch (_) {}
}
});
res.on('end', () => finish(null, true));
res.on('error', (err) => finish(fail(err.message)));
});
abortHolder.req = req;
activeRequests.add(req);
req.on('error', (err) => finish(fail(err.message)));
if (timeoutMs > 0 && typeof req.setTimeout === 'function') {
req.setTimeout(timeoutMs, () => {
try { req.destroy(); } catch (_) {}
finish(fail('Groq request timed out'));
});
}
req.write(payload);
req.end();
});
}
function cancel() {
for (const req of activeRequests) {
try { req.destroy(); } catch (_) {}
}
activeRequests.clear();
}
async function complete(opts, onEvent) {
const apiKey = String((opts && opts.apiKey) || '').trim();
const model = String((opts && opts.model) || '').trim();
if (!apiKey) throw new Error('Groq agent inference needs a Groq API key. Set it in Settings or GROQ_API_KEY.');
if (!model) throw new Error('Groq agent inference needs a groqModel.');
assertChatModel(model);
const vision = visionModel(model);
const tools = toOpenAiTools(opts && opts.tools);
const messages = toOpenAiMessages((opts && opts.history) || (opts && opts.messages) || [], { vision });
const effort = reasoningEffort(model);
const body = {
model,
messages,
stream: opts && opts.stream === false ? false : true,
temperature: 0.6,
...(effort ? { reasoning_effort: effort } : {}),
};
if (tools) {
body.tools = tools;
body.tool_choice = 'auto';
if (NO_PARALLEL.test(model)) body.parallel_tool_calls = false;
}
const abortHolder = { req: null };
const abortRun = () => {
try { if (abortHolder.req) abortHolder.req.destroy(); } catch (_) {}
};
let settleTimeout;
const timedOutGate = new Promise((resolve) => { settleTimeout = () => resolve('timeout'); });
const watch = completeWatch.attachCompleteWatch({
timeoutMs: opts && opts.timeoutMs,
idleMs: opts && opts.idleMs,
abort: abortRun,
onTimeout: settleTimeout,
});
const state = { text: '', thinking: '', toolCalls: [] };
try {
if (body.stream === false) {
const res = await Promise.race([
postChat(body, apiKey, { timeoutMs: (opts && opts.timeoutMs) || 0, abortHolder }),
timedOutGate.then(() => null),
]);
if (!res || watch.timedOut()) {
return { text: '', thinking: '', toolCalls: [], stats: null, requestId: null, stopReason: 'timeout' };
}
if (res.status >= 400) throw fail('Groq HTTP ' + res.status + ': ' + res.raw.slice(0, 400));
let parsed;
try { parsed = JSON.parse(res.raw); } catch (err) { throw fail('Groq returned invalid JSON'); }
const msg = parsed && parsed.choices && parsed.choices[0] && parsed.choices[0].message;
if (msg) {
if (msg.content) state.text = String(msg.content);
if (msg.reasoning || msg.reasoning_content) state.thinking = String(msg.reasoning || msg.reasoning_content);
if (Array.isArray(msg.tool_calls)) {
for (const call of msg.tool_calls) {
state.toolCalls.push({
id: call.id || '',
name: (call.function && call.function.name) || '',
arguments: (call.function && call.function.arguments) || '',
});
}
}
}
} else {
const consume = streamChat(body, apiKey, {
timeoutMs: (opts && opts.timeoutMs) || 0,
abortHolder,
watch,
onChunk: (evt) => {
const choice = evt && evt.choices && evt.choices[0];
if (!choice) return;
applyDelta(choice.delta || {}, state, onEvent);
if (choice.message) applyDelta(choice.message, state, onEvent);
},
});
consume.catch(() => {});
const raced = await Promise.race([consume.then(() => 'ok'), timedOutGate]);
if (raced !== 'ok' && raced !== 'timeout') throw raced;
}
const toolCalls = harvestedCalls(state.toolCalls);
let text = state.text;
let thinking = state.thinking;
if (tools && tools.length) {
const recovered = toolParse.recover({ text, thinking, tools: opts.tools, existing: toolCalls });
if (!toolCalls.length && recovered.calls.length) {
for (const call of recovered.calls) toolCalls.push(call);
}
text = recovered.text;
} else {
text = toolParse.stripToolMarkup(text);
}
return {
text,
thinking,
toolCalls,
stats: null,
requestId: null,
stopReason: watch.timedOut() ? 'timeout' : 'stop',
};
} finally {
watch.clear();
activeRequests.delete(abortHolder.req);
}
}
module.exports = {
GROQ_HOST,
GROQ_PATH,
groqHost,
visionModel,
reasoningEffort,
toOpenAiTools,
toOpenAiMessages,
setHttpsRequest,
complete,
cancel,
redact,
};