From 8bb2f4fc437a72ca9490bf5052b464c8a213bebe Mon Sep 17 00:00:00 2001 From: allocsys <225476909+allocsys@users.noreply.github.com> Date: Thu, 27 Aug 2026 03:24:01 +0330 Subject: [PATCH 01/17] Groq provider step 1: add config (GROQ_API_KEYS, GROQ_API, GROQ_MODEL, GROQ_FALLBACK_MODELS, GROQ_REQUEST_TIMEOUT_MS, GROQ_DEFAULT_MAX_OUTPUT_TOKENS). Model defaults per plan.md's corrected choice: openai/gpt-oss-120b (production) primary, qwen/qwen3.6-27b (preview, benchmark-strong but liable to disappear) as fallback only. GROQ_DEFAULT_MAX_OUTPUT_TOKENS set up front this time (unlike GLM_DEFAULT_MAX_OUTPUT_TOKENS, which was only added after a live 402) -- flagged in comments as unverified against Groq's actual param-name/limit behavior until the live smoke test in step 9. --- config.js | 57 +++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 57 insertions(+) diff --git a/config.js b/config.js index 4a1353c..4240de2 100644 --- a/config.js +++ b/config.js @@ -257,6 +257,63 @@ export const GLM_DEFAULT_MAX_OUTPUT_TOKENS = Number(process.env.GLM_DEFAULT_MAX_ // automatic best-model routing. export const DEFAULT_LLM_PROVIDER = process.env.DEFAULT_LLM_PROVIDER || "gemini"; +// --------------------------------------------------------------------------- +// Groq -- third `delegate_agent` provider option (see plan.md "Groq provider +// addition"), added because GLM/OpenRouter's free tier turned out to be +// gated by account credit balance (see plan.md "Current status" -- a +// zero-balance account is blocked from OpenRouter's free models too, not +// just paid ones). Groq's free tier is documented as request/token-rate- +// limited instead, not tied to a dollar balance, and needs no credit card. +// Also OpenAI-compatible like OpenRouter, so it reuses the same +// translation layer (see connectors/openai_shape/adapter.js, extracted +// from connectors/glm/adapter.js specifically so both providers share one +// implementation instead of two copies drifting apart). +// +// GROQ_API_KEYS is plural/comma-separated, same rotation pattern as +// OPENROUTER_API_KEYS/EXA_API_KEYS above. +export const GROQ_API_KEYS = (process.env.GROQ_API_KEYS || "") + .split(",") + .map((s) => s.trim()) + .filter(Boolean); +export const GROQ_API = "https://api.groq.com/openai/v1/chat/completions"; + +// Model choice verified directly against https://console.groq.com/docs/models +// on 2026-08-27 (not from third-party benchmarks alone): Groq explicitly +// classifies qwen/qwen3.6-27b as a PREVIEW model ("intended for evaluation +// purposes only... may be discontinued at short notice") despite it +// scoring highest on Groq's own intelligence ranking, while +// openai/gpt-oss-120b is a PRODUCTION model. For a persistent, unattended +// delegate_agent provider, availability stability matters more than a +// benchmark edge, so production is the default and the stronger-but- +// preview model is only the fallback -- do not swap this ordering without +// re-reading plan.md's "Model choice -- CORRECTED" note first. +export const GROQ_MODEL = process.env.GROQ_MODEL || "openai/gpt-oss-120b"; +export const GROQ_FALLBACK_MODELS = (process.env.GROQ_FALLBACK_MODELS || "qwen/qwen3.6-27b") + .split(",") + .map((s) => s.trim()) + .filter(Boolean); + +// Same defensive-ceiling reasoning as GEMINI_REQUEST_TIMEOUT_MS/ +// GLM_REQUEST_TIMEOUT_MS above. +export const GROQ_REQUEST_TIMEOUT_MS = Number(process.env.GROQ_REQUEST_TIMEOUT_MS) || 55000; + +// Default cap on Groq's max_tokens when a caller doesn't specify one +// explicitly -- set UP FRONT this time, unlike GLM_DEFAULT_MAX_OUTPUT_TOKENS, +// which was only added reactively after a live 402 revealed OpenRouter has +// no sane default at all (see plan.md's "Current status" and the Groq +// section's step 1 note: don't repeat that discovery-by-failure cycle). +// 4096 follows Groq's own tool-use guidance ("set max_completion_tokens to +// 3000-4000 for complex tasks" -- see console.groq.com/docs on built-in +// tool use). NOT YET LIVE-VERIFIED: Groq's chat completions endpoint is +// OpenAI-compatible, but it's unconfirmed whether it honors the legacy +// `max_tokens` field name (what connectors/openai_shape/adapter.js and +// glm/client.js both send) the same way for every model, or whether some +// Groq models expect the newer `max_completion_tokens` name instead -- +// this needs live-testing in plan.md step 9 before being treated as +// settled, exactly the kind of thing pre-emptive comments can flag but not +// substitute for actually running the smoke test. +export const GROQ_DEFAULT_MAX_OUTPUT_TOKENS = Number(process.env.GROQ_DEFAULT_MAX_OUTPUT_TOKENS) || 4096; + // --------------------------------------------------------------------------- // Frontend/design delegate (connectors/frontend/) -- delegate_designer's // write-capable agent loop (agent.js), backed by the existing Gemini From a82c7b26e6484ce15a1457bf9388dfa9bf00ec09 Mon Sep 17 00:00:00 2001 From: allocsys <225476909+allocsys@users.noreply.github.com> Date: Thu, 27 Aug 2026 03:24:30 +0330 Subject: [PATCH 02/17] Groq provider step 3: extract connectors/glm/adapter.js's pure OpenAI-shape translation into a shared connectors/openai_shape/adapter.js, so Groq (another OpenAI-compatible provider) doesn't get a duplicate copy of the same three functions. Content is a verbatim move (no logic changes) -- glm/adapter.js becomes a thin re-export in the next commit to preserve existing imports/tests. --- connectors/openai_shape/adapter.js | 167 +++++++++++++++++++++++++++++ 1 file changed, 167 insertions(+) create mode 100644 connectors/openai_shape/adapter.js diff --git a/connectors/openai_shape/adapter.js b/connectors/openai_shape/adapter.js new file mode 100644 index 0000000..042c49d --- /dev/null +++ b/connectors/openai_shape/adapter.js @@ -0,0 +1,167 @@ +// --------------------------------------------------------------------------- +// connectors/openai_shape/adapter.js — pure translation between Gemini's +// `contents`/`candidate` wire shape (the lingua franca agent_delegate.js's +// loop, checkpointing, and stuck-loop/step-budget logic are all built +// around) and the OpenAI-compatible `messages`/`choice` shape used by ANY +// OpenAI-compatible chat-completions provider. +// +// EXTRACTED 2026-08-27 from connectors/glm/adapter.js when Groq was added +// as a second OpenAI-compatible provider alongside GLM/OpenRouter (see +// plan.md "Groq provider addition", step 3). Nothing in here was ever +// OpenRouter-specific -- it was already pure OpenAI-shape translation, so +// this is a verbatim move, not a rewrite. connectors/glm/adapter.js now +// re-exports from here (see that file) so existing imports/tests didn't +// need to change; connectors/llm/router.js imports directly from here for +// both the "glm" and "groq" branches, since duplicating this file per +// provider would just recreate the drift risk this extraction exists to +// avoid. +// +// Three pure functions: +// +// toOpenAIMessages(contents) Gemini contents -> OpenAI messages +// toOpenAITools(functionDeclarations) Gemini tools wrapper -> OpenAI tools +// fromOpenAIChoice(choice) OpenAI choice -> Gemini candidate +// +// This is the highest-risk file behind every OpenAI-compatible provider +// (a subtle role/shape mismatch here would silently corrupt checkpointed +// conversations for BOTH glm and groq at once) -- see +// test/openai-shape-adapter.test.js for the round-trip test this was +// written against (moved from test/glm-adapter.test.js's original +// coverage, same reasoning as the code move above). +// --------------------------------------------------------------------------- + +// Gemini `contents` shape: an array of +// { role: "user"|"model", parts: [ {text} | {functionCall:{name,args,id}} | {functionResponse:{name,id,response:{result}}} ] } +// Function-call RESULTS go back as role "user" wrapping a functionResponse +// part (current Gemini 3 contract, see gemini/client.js's own header) -- +// NOT a distinct "function" role, and a single "user" turn can mix +// functionResponse parts with plain SYSTEM NOTE text parts (agent_delegate.js +// appends step-budget/stuck-loop nudges onto the same responseParts array +// as the functionResponse entries for that step). +// +// OpenAI chat shape has no equivalent of "one turn, several kinds of +// content" for a tool-result turn: each tool result is its OWN message +// with role "tool" and a tool_call_id, and any plain user-facing text has +// to be its own separate "user" message. So one Gemini "user" turn can +// expand into MULTIPLE OpenAI messages (one "tool" message per +// functionResponse part, in original order, followed by a "user" message +// if there's leftover plain text) -- order matters here: OpenAI requires +// every "tool" message immediately following the assistant message that +// requested it, before any new "user" content. +export function toOpenAIMessages(contents) { + const messages = []; + for (const turn of contents || []) { + const parts = turn.parts || []; + if (turn.role === "model") { + const functionCallParts = parts.filter((p) => p.functionCall); + const textParts = parts.filter((p) => p.text !== undefined && p.text !== null); + const message = { role: "assistant", content: textParts.map((p) => p.text).join("") || null }; + if (functionCallParts.length) { + message.tool_calls = functionCallParts.map((p) => ({ + // Gemini always assigns functionCall.id itself when it emits a + // call -- the fallback below only guards a synthetic/test + // `contents` array that omits it, not anything the real + // client.js/agent_delegate.js loop produces. + id: p.functionCall.id || `call_${Math.random().toString(36).slice(2, 10)}`, + type: "function", + function: { + name: p.functionCall.name, + arguments: JSON.stringify(p.functionCall.args || {}), + }, + })); + } + messages.push(message); + } else { + // role "user" -- may be the original task text (turn 1), a batch of + // functionResponse results, or (per agent_delegate.js's step-budget / + // stuck-loop nudges) a mix of functionResponse parts plus a trailing + // plain-text SYSTEM NOTE in the SAME turn. + const functionResponseParts = parts.filter((p) => p.functionResponse); + const textParts = parts.filter((p) => p.text !== undefined && p.text !== null && !p.functionResponse); + + for (const p of functionResponseParts) { + const result = p.functionResponse.response?.result; + messages.push({ + role: "tool", + tool_call_id: p.functionResponse.id, + content: typeof result === "string" ? result : JSON.stringify(result ?? p.functionResponse.response ?? ""), + }); + } + + const text = textParts.map((p) => p.text).join(""); + if (text) { + messages.push({ role: "user", content: text }); + } + } + } + return messages; +} + +// `tools` as agent_delegate.js actually passes it is FUNCTION_DECLARATIONS: +// `[{ functionDeclarations: FUNCTIONS.map(({name,description,parameters}) => ({...})) }]` +// -- a one-element array wrapping an object keyed `functionDeclarations`, +// Gemini's specific wire shape. Unwrap that before mapping each +// {name, description, parameters} entry into OpenAI's +// {type:"function", function:{name, description, parameters}} shape. +// Treating the incoming value as already a flat array of declarations (an +// earlier draft of the plan this was built from assumed this) would +// silently produce zero tools -- see plan.md's GLM step 3 "corrected from +// the original draft" note. +export function toOpenAITools(tools) { + if (!tools) return undefined; + const declarations = Array.isArray(tools) + ? tools.flatMap((t) => t?.functionDeclarations || []) + : (tools.functionDeclarations || []); + if (!declarations.length) return undefined; + return declarations.map(({ name, description, parameters }) => ({ + type: "function", + function: { name, description, parameters }, + })); +} + +// OpenAI `choice` shape: { message: { role, content, tool_calls? }, +// finish_reason }. Produces the same `candidate` shape agent_delegate.js +// already consumes from geminiChat: { content: { role: "model", parts: +// [...] }, finishReason }. +// +// KNOWN, ACCEPTED ASYMMETRY (see plan.md's GLM step 3): agent_delegate.js +// special-cases candidate.finishReason === "MALFORMED_FUNCTION_CALL" to +// give an actionable error when the final/stuck-loop step withholds tools +// but the model tries to call one anyway. That's a Gemini-specific +// rejection code -- an OpenAI-compatible API with no `tools` in the +// request body has no way to attempt a tool call at all (it just returns +// plain text, or a finish_reason like "length"), so this function will +// never produce that finishReason value for any OpenAI-shaped provider +// (GLM or Groq). Not a bug to patch here: it means their failure message +// on that specific edge case is the generic "stopped without a final +// answer -- finishReason: stop" rather than the Gemini-specific +// diagnostic. Do not invent a fake MALFORMED_FUNCTION_CALL equivalent to +// paper over this. +export function fromOpenAIChoice(choice) { + const message = choice?.message || {}; + const parts = []; + + if (message.content) { + parts.push({ text: message.content }); + } + if (message.tool_calls?.length) { + for (const toolCall of message.tool_calls) { + let args; + try { + args = toolCall.function?.arguments ? JSON.parse(toolCall.function.arguments) : {}; + } catch { + // Malformed JSON from the model -- pass through an empty args + // object rather than throwing here; the downstream function + // execute() call will surface a clearer error for the specific + // function than a raw JSON.parse failure would. + args = {}; + } + parts.push({ functionCall: { name: toolCall.function?.name, args, id: toolCall.id } }); + } + } + + return { + content: { role: "model", parts }, + finishReason: choice?.finish_reason, + }; +} From c8c0fce2068205f9574248391070f37b3b710a87 Mon Sep 17 00:00:00 2001 From: allocsys <225476909+allocsys@users.noreply.github.com> Date: Thu, 27 Aug 2026 03:24:45 +0330 Subject: [PATCH 03/17] Groq provider step 3 (cont.): collapse connectors/glm/adapter.js to a thin re-export of the newly-extracted connectors/openai_shape/adapter.js. Preserves the existing import path (../glm/adapter.js) for any code/tests that still reference it -- test/glm-adapter.test.js keeps passing unchanged since the functions it imports still exist at the same path, just forwarded. --- connectors/glm/adapter.js | 167 ++++---------------------------------- 1 file changed, 18 insertions(+), 149 deletions(-) diff --git a/connectors/glm/adapter.js b/connectors/glm/adapter.js index 696ad9e..72687b8 100644 --- a/connectors/glm/adapter.js +++ b/connectors/glm/adapter.js @@ -1,154 +1,23 @@ // --------------------------------------------------------------------------- -// connectors/glm/adapter.js — the actual seam of the GLM provider switch. -// See plan.md "Add GLM (via OpenRouter) as a switchable alternative to -// Gemini", step 3. +// connectors/glm/adapter.js — thin re-export. // -// Three pure functions translate between Gemini's `contents`/`candidate` -// wire shape (the lingua franca agent_delegate.js's loop, checkpointing, -// and stuck-loop/step-budget logic are all built around) and OpenRouter's -// OpenAI-compatible `messages`/`choice` shape: +// EXTRACTED 2026-08-27 (see plan.md "Groq provider addition", step 3): this +// file used to contain the full toOpenAIMessages/toOpenAITools/ +// fromOpenAIChoice implementation, but none of it was ever OpenRouter- +// specific -- it's pure Gemini-shape <-> OpenAI-shape translation, equally +// applicable to any OpenAI-compatible provider. When Groq was added as a +// second such provider, the implementation moved to +// connectors/openai_shape/adapter.js so both providers share one copy +// instead of two that could silently drift apart (see that file's header +// for the full history/reasoning). // -// toOpenAIMessages(contents) Gemini contents -> OpenAI messages -// toOpenAITools(functionDeclarations) Gemini tools wrapper -> OpenAI tools -// fromOpenAIChoice(choice) OpenAI choice -> Gemini candidate -// -// This is the highest-risk file in the whole GLM provider switch (a subtle -// role/shape mismatch here would silently corrupt checkpointed -// conversations) -- see test/glm-adapter.test.js for the round-trip test -// this was written against. +// This file stays as a re-export, not a deleted/renamed import site, +// specifically so nothing that already imports "../glm/adapter.js" (code +// or tests) needed to change as part of this extraction. New code should +// prefer importing directly from connectors/openai_shape/adapter.js -- +// connectors/llm/router.js does, for both its "glm" and "groq" branches -- +// but this path keeps working indefinitely, not just as a deprecation +// grace period. // --------------------------------------------------------------------------- -// Gemini `contents` shape: an array of -// { role: "user"|"model", parts: [ {text} | {functionCall:{name,args,id}} | {functionResponse:{name,id,response:{result}}} ] } -// Function-call RESULTS go back as role "user" wrapping a functionResponse -// part (current Gemini 3 contract, see gemini/client.js's own header) -- -// NOT a distinct "function" role, and a single "user" turn can mix -// functionResponse parts with plain SYSTEM NOTE text parts (agent_delegate.js -// appends step-budget/stuck-loop nudges onto the same responseParts array -// as the functionResponse entries for that step). -// -// OpenAI chat shape has no equivalent of "one turn, several kinds of -// content" for a tool-result turn: each tool result is its OWN message -// with role "tool" and a tool_call_id, and any plain user-facing text has -// to be its own separate "user" message. So one Gemini "user" turn can -// expand into MULTIPLE OpenAI messages (one "tool" message per -// functionResponse part, in original order, followed by a "user" message -// if there's leftover plain text) -- order matters here: OpenAI requires -// every "tool" message immediately following the assistant message that -// requested it, before any new "user" content. -export function toOpenAIMessages(contents) { - const messages = []; - for (const turn of contents || []) { - const parts = turn.parts || []; - if (turn.role === "model") { - const functionCallParts = parts.filter((p) => p.functionCall); - const textParts = parts.filter((p) => p.text !== undefined && p.text !== null); - const message = { role: "assistant", content: textParts.map((p) => p.text).join("") || null }; - if (functionCallParts.length) { - message.tool_calls = functionCallParts.map((p) => ({ - // Gemini always assigns functionCall.id itself when it emits a - // call -- the fallback below only guards a synthetic/test - // `contents` array that omits it, not anything the real - // client.js/agent_delegate.js loop produces. - id: p.functionCall.id || `call_${Math.random().toString(36).slice(2, 10)}`, - type: "function", - function: { - name: p.functionCall.name, - arguments: JSON.stringify(p.functionCall.args || {}), - }, - })); - } - messages.push(message); - } else { - // role "user" -- may be the original task text (turn 1), a batch of - // functionResponse results, or (per agent_delegate.js's step-budget / - // stuck-loop nudges) a mix of functionResponse parts plus a trailing - // plain-text SYSTEM NOTE in the SAME turn. - const functionResponseParts = parts.filter((p) => p.functionResponse); - const textParts = parts.filter((p) => p.text !== undefined && p.text !== null && !p.functionResponse); - - for (const p of functionResponseParts) { - const result = p.functionResponse.response?.result; - messages.push({ - role: "tool", - tool_call_id: p.functionResponse.id, - content: typeof result === "string" ? result : JSON.stringify(result ?? p.functionResponse.response ?? ""), - }); - } - - const text = textParts.map((p) => p.text).join(""); - if (text) { - messages.push({ role: "user", content: text }); - } - } - } - return messages; -} - -// `tools` as agent_delegate.js actually passes it is FUNCTION_DECLARATIONS: -// `[{ functionDeclarations: FUNCTIONS.map(({name,description,parameters}) => ({...})) }]` -// -- a one-element array wrapping an object keyed `functionDeclarations`, -// Gemini's specific wire shape. Unwrap that before mapping each -// {name, description, parameters} entry into OpenAI's -// {type:"function", function:{name, description, parameters}} shape. -// Treating the incoming value as already a flat array of declarations (an -// earlier draft of the plan this was built from assumed this) would -// silently produce zero tools -- see plan.md step 3's "corrected from the -// original draft" note. -export function toOpenAITools(tools) { - if (!tools) return undefined; - const declarations = Array.isArray(tools) - ? tools.flatMap((t) => t?.functionDeclarations || []) - : (tools.functionDeclarations || []); - if (!declarations.length) return undefined; - return declarations.map(({ name, description, parameters }) => ({ - type: "function", - function: { name, description, parameters }, - })); -} - -// OpenAI `choice` shape: { message: { role, content, tool_calls? }, -// finish_reason }. Produces the same `candidate` shape agent_delegate.js -// already consumes from geminiChat: { content: { role: "model", parts: -// [...] }, finishReason }. -// -// KNOWN, ACCEPTED ASYMMETRY (see plan.md step 3): agent_delegate.js -// special-cases candidate.finishReason === "MALFORMED_FUNCTION_CALL" to -// give an actionable error when the final/stuck-loop step withholds tools -// but the model tries to call one anyway. That's a Gemini-specific -// rejection code -- an OpenAI-compatible API with no `tools` in the -// request body has no way to attempt a tool call at all (it just returns -// plain text, or a finish_reason like "length"), so this function will -// never produce that finishReason value for GLM. Not a bug to patch here: -// it means GLM's failure message on that specific edge case is the -// generic "Gemini stopped without a final answer -- finishReason: stop" -// rather than the Gemini-specific diagnostic. Do not invent a fake -// MALFORMED_FUNCTION_CALL equivalent to paper over this. -export function fromOpenAIChoice(choice) { - const message = choice?.message || {}; - const parts = []; - - if (message.content) { - parts.push({ text: message.content }); - } - if (message.tool_calls?.length) { - for (const toolCall of message.tool_calls) { - let args; - try { - args = toolCall.function?.arguments ? JSON.parse(toolCall.function.arguments) : {}; - } catch { - // Malformed JSON from the model -- pass through an empty args - // object rather than throwing here; the downstream function - // execute() call will surface a clearer error for the specific - // function than a raw JSON.parse failure would. - args = {}; - } - parts.push({ functionCall: { name: toolCall.function?.name, args, id: toolCall.id } }); - } - } - - return { - content: { role: "model", parts }, - finishReason: choice?.finish_reason, - }; -} +export { toOpenAIMessages, toOpenAITools, fromOpenAIChoice } from "../openai_shape/adapter.js"; From 0fe7fe9476f377b8b413942a6d7a3f45e926ff10 Mon Sep 17 00:00:00 2001 From: allocsys <225476909+allocsys@users.noreply.github.com> Date: Thu, 27 Aug 2026 03:25:14 +0330 Subject: [PATCH 04/17] Groq provider step 2: add connectors/groq/client.js, a thin OpenAI-compatible wire client modeled closely on connectors/glm/client.js. Key differences from GLM: single-vendor endpoint (no OpenRouter-style headers needed), error/message strings say "Groq" not "OpenRouter", and env var names are GROQ_* throughout. Cascade shape (outer over GROQ_API_KEYS on 401/403/429, inner over GROQ_MODEL + GROQ_FALLBACK_MODELS on 429/503/transient) and cooldown-namespace pattern (groq:${keyIndex}) are unchanged from GLM's client, reusing the same connectors/gemini/cooldown.js module (already provider-agnostic). --- connectors/groq/client.js | 151 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 151 insertions(+) create mode 100644 connectors/groq/client.js diff --git a/connectors/groq/client.js b/connectors/groq/client.js new file mode 100644 index 0000000..6d34632 --- /dev/null +++ b/connectors/groq/client.js @@ -0,0 +1,151 @@ +// --------------------------------------------------------------------------- +// connectors/groq/client.js — Groq (api.groq.com), an OpenAI-compatible +// chat completions API. Structurally parallel to connectors/glm/client.js +// (itself parallel to connectors/gemini/client.js) -- see plan.md "Groq +// provider addition". +// +// SAME TWO CASCADE AXES AS GLM, for the same reasons (see glm/client.js's +// header for the full explanation): outer cascade over GROQ_API_KEYS on +// 401/403/429 (bad/exhausted key), inner cascade over GROQ_MODEL + +// GROQ_FALLBACK_MODELS on 429/503/network-transient. Cooldown is namespaced +// per (model, key-index) via "groq:", reusing +// connectors/gemini/cooldown.js's generic `namespace` param -- no changes +// needed there, it was already provider-agnostic (verified by direct read +// when this file was written, not assumed from GLM's integration alone). +// +// UNLIKE GLM: no OpenRouter-style cosmetic attribution headers -- Groq is a +// single vendor, not a router across many upstream providers, so there's +// no equivalent "which app is this usage attributed to" concept to send. +// +// This file stays a thin, faithful wire-format client -- format translation +// between Gemini's `contents`/`candidate` shape and OpenAI's +// `messages`/`choice` shape happens in connectors/openai_shape/adapter.js +// (shared with GLM), called from connectors/llm/router.js, not here. +// --------------------------------------------------------------------------- + +import { GROQ_API_KEYS, GROQ_API, GROQ_MODEL, GROQ_FALLBACK_MODELS, GROQ_REQUEST_TIMEOUT_MS } from "../../config.js"; +import { isModelCoolingDown, setModelCooldown, parseRetryDelaySeconds } from "../gemini/cooldown.js"; + +async function callChatCompletionOnce(body, model, apiKey) { + if (!apiKey) throw new Error("No Groq API key available. Set GROQ_API_KEYS as an environment variable on the madmcp server."); + + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), GROQ_REQUEST_TIMEOUT_MS); + + let res; + try { + res = await fetch(GROQ_API, { + method: "POST", + headers: { + Authorization: `Bearer ${apiKey}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ ...body, model }), + signal: controller.signal, + }); + } catch (err) { + // Same reasoning as glm/client.js's callChatCompletionOnce: a + // network-level failure carries no HTTP status, so `transient: true` + // lets the cascade below treat it the same as a 503. + const isAbort = err.name === "AbortError"; + const wrapped = new Error(isAbort ? `Groq request timed out after ${GROQ_REQUEST_TIMEOUT_MS}ms (model: ${model})` : `Groq request failed (network error, model: ${model}): ${err.message}`); + wrapped.transient = true; + throw wrapped; + } finally { + clearTimeout(timeout); + } + + const text = await res.text(); + let data; + try { data = text ? JSON.parse(text) : null; } catch { data = text; } + + if (!res.ok) { + const message = (data && (data.error?.message || JSON.stringify(data))) || res.statusText; + const err = new Error(`Groq API error (${res.status}): ${message}`); + err.status = res.status; + throw err; + } + return data; +} + +// Cascades through (GROQ_API_KEYS x [GROQ_MODEL, ...GROQ_FALLBACK_MODELS]) +// on 401/403 (bad/exhausted key), 429 (rate limit), or 503 (overloaded). +// Same "any other status is a real failure, surface immediately" rule as +// Gemini's and GLM's clients. If the caller passed an explicit `model` +// that differs from the configured default (GROQ_MODEL), that choice is +// honored exactly with no model cascade (same contract as the other two +// providers) -- but key rotation still applies, since a bad/exhausted key +// isn't a model choice. +async function callChatCompletion(body, requestedModel) { + if (!GROQ_API_KEYS.length) { + throw new Error("GROQ_API_KEYS is not set. Add at least one Groq API key as an environment variable on the madmcp server."); + } + const models = requestedModel && requestedModel !== GROQ_MODEL + ? [requestedModel] + : [GROQ_MODEL, ...GROQ_FALLBACK_MODELS.filter((m) => m !== GROQ_MODEL)]; + + let lastErr; + for (let keyIndex = 0; keyIndex < GROQ_API_KEYS.length; keyIndex++) { + const apiKey = GROQ_API_KEYS[keyIndex]; + const namespace = `groq:${keyIndex}`; + const isLastKey = keyIndex === GROQ_API_KEYS.length - 1; + + for (let i = 0; i < models.length; i++) { + const model = models[i]; + const isLastModelForKey = i === models.length - 1; + if (await isModelCoolingDown(model, namespace)) { + lastErr = lastErr || new Error(`Groq API error (429): model "${model}" on key #${keyIndex} is in a recorded cooldown from a recent rate limit.`); + continue; + } + try { + const data = await callChatCompletionOnce(body, model, apiKey); + if (keyIndex > 0 || i > 0) data._fallbackModelUsed = model; // surfaced for logging/debugging only + return data; + } catch (err) { + lastErr = err; + const isBadKey = err.status === 401 || err.status === 403; + const isRateLimited = err.status === 429; + const isOverloaded = err.status === 503; + const isNetworkTransient = err.transient === true; + // A bad/exhausted key (401/403) isn't a model problem -- no point + // cascading through the rest of this key's model list, jump + // straight to the next key instead. + if (isBadKey) break; + if (!isRateLimited && !isOverloaded && !isNetworkTransient) throw err; + if (isRateLimited) { + await setModelCooldown(model, parseRetryDelaySeconds(err.message), namespace); + } + if (isLastModelForKey && isLastKey) throw err; + // Otherwise fall through -- either to the next model on this key, + // or (via the outer loop) to the next key. + } + } + } + throw lastErr; +} + +// Multi-turn call with function-calling support, mirroring glmChat's role: +// takes/returns OpenAI-shaped `messages`/`choice` (NOT Gemini's +// `contents`/`candidate` -- that translation is +// connectors/openai_shape/adapter.js's job, called from +// connectors/llm/router.js, not from here). `tools` is an OpenAI-shaped +// tools array or undefined -- passing undefined omits `tools` from the +// request body entirely (not an empty array), matching Gemini/GLM's +// "withhold tools" behavior on the final/stuck-loop step. +export async function groqChat(messages, { model = GROQ_MODEL, tools, maxOutputTokens } = {}) { + const body = { messages }; + if (tools) body.tools = tools; + // NOTE (see config.js's GROQ_DEFAULT_MAX_OUTPUT_TOKENS comment): sent as + // `max_tokens`, matching GLM's client and the OpenAI legacy field name. + // Not yet live-verified against Groq's actual API behavior for every + // model -- flagged for the live smoke test in plan.md step 9, not + // assumed correct just because it mirrors GLM. + if (maxOutputTokens) body.max_tokens = maxOutputTokens; + + const data = await callChatCompletion(body, model); + const choice = data?.choices?.[0]; + if (!choice) { + throw new Error("Groq returned no choices."); + } + return choice; // { message: { role, content, tool_calls? }, finish_reason, ... } +} From b860af8ba2c6c3d614230d3ccee6205e8b412e97 Mon Sep 17 00:00:00 2001 From: allocsys <225476909+allocsys@users.noreply.github.com> Date: Thu, 27 Aug 2026 03:25:36 +0330 Subject: [PATCH 05/17] Groq provider step 4: add a "groq" branch to providerChat, mirroring the existing "glm" branch (same adapter reuse, same "explicit maxOutputTokens wins, otherwise apply the provider's own default" contract). Also switches the adapter import to the new shared connectors/openai_shape/adapter.js path (previously ../glm/adapter.js, which still re-exports the same functions -- see that file's step-3 commit -- but importing from the canonical shared module directly is clearer now that two providers depend on it). --- connectors/llm/router.js | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/connectors/llm/router.js b/connectors/llm/router.js index b8f213e..c17697d 100644 --- a/connectors/llm/router.js +++ b/connectors/llm/router.js @@ -20,8 +20,9 @@ import { geminiChat } from "../gemini/client.js"; import { glmChat } from "../glm/client.js"; -import { toOpenAIMessages, toOpenAITools, fromOpenAIChoice } from "../glm/adapter.js"; -import { GLM_DEFAULT_MAX_OUTPUT_TOKENS } from "../../config.js"; +import { groqChat } from "../groq/client.js"; +import { toOpenAIMessages, toOpenAITools, fromOpenAIChoice } from "../openai_shape/adapter.js"; +import { GLM_DEFAULT_MAX_OUTPUT_TOKENS, GROQ_DEFAULT_MAX_OUTPUT_TOKENS } from "../../config.js"; export async function providerChat(contents, { provider = "gemini", tools, model, maxOutputTokens } = {}) { if (provider === "glm") { @@ -37,6 +38,17 @@ export async function providerChat(contents, { provider = "gemini", tools, model const choice = await glmChat(messages, { model, tools: openAITools, maxOutputTokens: maxOutputTokens ?? GLM_DEFAULT_MAX_OUTPUT_TOKENS }); return fromOpenAIChoice(choice); } + if (provider === "groq") { + // Same adapter reuse and "explicit value wins, otherwise apply the + // provider's own default" contract as the glm branch above -- see + // connectors/groq/client.js's header and config.js's + // GROQ_DEFAULT_MAX_OUTPUT_TOKENS comment for why a default is applied + // pre-emptively here rather than after a live failure, unlike GLM's. + const messages = toOpenAIMessages(contents); + const openAITools = tools ? toOpenAITools(tools) : undefined; + const choice = await groqChat(messages, { model, tools: openAITools, maxOutputTokens: maxOutputTokens ?? GROQ_DEFAULT_MAX_OUTPUT_TOKENS }); + return fromOpenAIChoice(choice); + } // default / "gemini" -- maxOutputTokens passed through as-is, no forced // default (see config.js's comment: this problem was only observed on // the GLM path, so Gemini's existing unbounded-by-default behavior is From 6123b8f501d88234a8d82581285a17621e889be9 Mon Sep 17 00:00:00 2001 From: allocsys <225476909+allocsys@users.noreply.github.com> Date: Thu, 27 Aug 2026 03:26:06 +0330 Subject: [PATCH 06/17] Groq provider step 7: add "groq" to delegate_agent's provider zod enum (zod enums don't silently accept unlisted values, so this was a required change, not optional). Updated the provider/model/maxOutputTokens descriptions to mention Groq alongside Gemini/GLM. --- connectors/gemini/agent_tools.js | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/connectors/gemini/agent_tools.js b/connectors/gemini/agent_tools.js index a655299..2b0cb68 100644 --- a/connectors/gemini/agent_tools.js +++ b/connectors/gemini/agent_tools.js @@ -18,7 +18,7 @@ import { z } from "zod"; import { runInvestigation } from "./agent_delegate.js"; import { doCreatePage } from "../notion/tools.js"; -import { GEMINI_NOTION_ROOT_PAGE_ID, DEFAULT_LLM_PROVIDER, GLM_DEFAULT_MAX_OUTPUT_TOKENS } from "../../config.js"; +import { GEMINI_NOTION_ROOT_PAGE_ID, DEFAULT_LLM_PROVIDER, GLM_DEFAULT_MAX_OUTPUT_TOKENS, GROQ_DEFAULT_MAX_OUTPUT_TOKENS } from "../../config.js"; export function register(server) { @@ -35,20 +35,20 @@ export function register(server) { log_to_notion: z.boolean().optional().describe("Whether to log the task, step-by-step tool calls, and final answer as a page under the Gemini section of Notion (default: false). Write always targets the fixed Gemini root page."), resume_run_id: z.string().optional().describe("A runId returned from a previous failed/partial delegate_agent call. If its checkpoint is still live (1 hour TTL), continues that run's conversation instead of starting fresh."), show_transcript: z.boolean().optional().describe("Include the full step-by-step tool-call transcript in the response, even on a successful run (default: false). Useful for debugging what Gemini actually called and in what order/grouping -- e.g. checking whether independent calls were batched into the same step. On a failed/partial run the transcript is always shown regardless of this flag."), - provider: z.enum(["gemini", "glm"]).optional() + provider: z.enum(["gemini", "glm", "groq"]).optional() .describe(`DEFAULT: "${DEFAULT_LLM_PROVIDER}". ` + - `VALUES: "gemini" (Google Gemini API, needs GEMINI_API_KEY) | "glm" (Z.ai GLM via OpenRouter, needs OPENROUTER_API_KEYS). ` + - `CHOOSE: the two are interchangeable in capability, not just cost/speed -- switch to "glm" if Gemini's output has been unreliable for this task. ` + + `VALUES: "gemini" (Google Gemini API, needs GEMINI_API_KEY) | "glm" (Z.ai GLM via OpenRouter, needs OPENROUTER_API_KEYS -- NOTE 2026-08-27: currently non-functional on this deployment, OpenRouter account has no credit and none is being added; a zero-balance account is blocked from both paid and free OpenRouter routes, see plan.md "Current status") | "groq" (Groq, needs GROQ_API_KEYS -- the practical free-tier alternative while glm is blocked, request/token-rate-limited rather than credit-balance-gated). ` + + `CHOOSE: all three are interchangeable in capability, not just cost/speed -- switch away from "gemini" if its output has been unreliable for this task, or if it's hit its own rate-limit cooldown. ` + `RESUME RULE: if resume_run_id resolves to a checkpoint that recorded a provider (any run started after this field existed), that recorded provider is always used and this argument is ignored -- switching providers mid-run risks corrupting the checkpointed conversation. If the checkpoint has no recorded provider (an older run), this argument is used as a fallback instead of erroring.`), model: z.string().optional() - .describe(`DEFAULT: none set -- the chosen provider's own default model is used (GEMINI_MODEL or GLM_MODEL from config). ` + - `USE: override the specific model within the chosen provider, e.g. model: "z-ai/glm-4.5-air:free" with provider: "glm" to force OpenRouter's free-tier model instead of the default paid GLM_MODEL (useful when the account is low on OpenRouter credits). ` + - `WARNING -- CASCADE DISABLED: passing a model that differs from the provider's own default model skips that provider's fallback-model list entirely (GLM_FALLBACK_MODELS / GEMINI_FALLBACK_MODELS are NOT tried) -- only the requested model is used, so a 429/503 on it fails the call instead of cascading to another model. API-key rotation (OPENROUTER_API_KEYS) is unaffected either way and still applies. ` + + .describe(`DEFAULT: none set -- the chosen provider's own default model is used (GEMINI_MODEL, GLM_MODEL, or GROQ_MODEL from config, depending on provider). ` + + `USE: override the specific model within the chosen provider, e.g. model: "qwen/qwen3.6-27b" with provider: "groq" to use Groq's stronger-but-preview coding model instead of the production default, or model: "z-ai/glm-4.5-air:free" with provider: "glm" to force OpenRouter's free-tier model instead of the default paid GLM_MODEL. ` + + `WARNING -- CASCADE DISABLED: passing a model that differs from the provider's own default model skips that provider's fallback-model list entirely (GLM_FALLBACK_MODELS / GROQ_FALLBACK_MODELS / GEMINI_FALLBACK_MODELS are NOT tried) -- only the requested model is used, so a 429/503 on it fails the call instead of cascading to another model. API-key rotation (OPENROUTER_API_KEYS / GROQ_API_KEYS) is unaffected either way and still applies. ` + `RESUME RULE: same as provider -- if resume_run_id resolves to a checkpoint that recorded a model, that recorded model is always used and this argument is ignored. If the checkpoint has no recorded model (an older run, or a run that didn't specify one), this argument is used as a fallback instead of erroring.`), maxOutputTokens: z.number().optional() - .describe(`DEFAULT: for provider "gemini", none set (Gemini's own API default applies, no cap sent). For provider "glm", ${GLM_DEFAULT_MAX_OUTPUT_TOKENS} (GLM_DEFAULT_MAX_OUTPUT_TOKENS from config) if this argument is omitted. ` + - `USE: caps the per-turn (not whole-conversation) output token budget for each model call in the investigation loop. Raise this if answers are getting cut off mid-response; lower it if OpenRouter credits are tight. ` + - `WHY GLM NEEDS A DEFAULT: with no max_tokens at all, OpenRouter defaults a request to the target model's FULL max context (e.g. 65536 for z-ai/glm-4.6) -- on a credit-limited account this fails EVERY GLM call with a 402 "requires more credits, or fewer max_tokens" error regardless of which model is selected, so provider "glm" always sends a value even when this argument is omitted. ` + + .describe(`DEFAULT: for provider "gemini", none set (Gemini's own API default applies, no cap sent). For provider "glm", ${GLM_DEFAULT_MAX_OUTPUT_TOKENS} (GLM_DEFAULT_MAX_OUTPUT_TOKENS from config) if this argument is omitted. For provider "groq", ${GROQ_DEFAULT_MAX_OUTPUT_TOKENS} (GROQ_DEFAULT_MAX_OUTPUT_TOKENS from config) if omitted. ` + + `USE: caps the per-turn (not whole-conversation) output token budget for each model call in the investigation loop. Raise this if answers are getting cut off mid-response; lower it if OpenRouter credits are tight (glm) or Groq's per-model token-per-minute limit is being hit. ` + + `WHY GLM/GROQ NEED A DEFAULT: with no max_tokens at all, OpenRouter defaults a request to the target model's FULL max context (e.g. 65536 for z-ai/glm-4.6) -- on a credit-limited account this fails EVERY GLM call with a 402 "requires more credits, or fewer max_tokens" error regardless of which model is selected, so provider "glm" always sends a value even when this argument is omitted. Groq's default is set pre-emptively for the same class of reason, though this hasn't (yet) been forced by an identical live failure the way GLM's was. ` + `RESUME RULE: same as provider/model -- if resume_run_id resolves to a checkpoint that recorded a value, that recorded value is always used and this argument is ignored. If the checkpoint has no recorded value (an older run), this argument (or the provider default above) is used as a fallback instead of erroring.`), }, async ({ task, max_steps = 20, log_to_notion = false, resume_run_id, show_transcript = false, provider, model, maxOutputTokens }) => { From 6b62a8f1aa8ac359042c8ec2319033290354cad3 Mon Sep 17 00:00:00 2001 From: allocsys <225476909+allocsys@users.noreply.github.com> Date: Thu, 27 Aug 2026 03:26:38 +0330 Subject: [PATCH 07/17] Groq provider step 8a: add test/groq-client.test.js, mirroring test/glm-client.test.js's coverage (key/model cascade, cooldown skip, bad-key rotation, explicit-model cascade-disable, network/abort transient mapping, no-choices error). Groq has no OpenRouter-style key-switch-on-401/403-only distinction to worry about differently -- same cascade rules apply, just against GROQ_* config and "Groq API error" message strings instead of "OpenRouter API error". --- test/groq-client.test.js | 182 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 182 insertions(+) create mode 100644 test/groq-client.test.js diff --git a/test/groq-client.test.js b/test/groq-client.test.js new file mode 100644 index 0000000..da5ddc5 --- /dev/null +++ b/test/groq-client.test.js @@ -0,0 +1,182 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; + +// Hoisted config mock, mirroring test/glm-client.test.js's pattern. +const mockConfig = vi.hoisted(() => ({ + GROQ_API_KEYS: ["key-a", "key-b"], + GROQ_API: "https://api.groq.com/openai/v1/chat/completions", + GROQ_MODEL: "openai/gpt-oss-120b", + GROQ_FALLBACK_MODELS: ["qwen/qwen3.6-27b"], + GROQ_REQUEST_TIMEOUT_MS: 55000, +})); + +vi.mock("../config.js", () => mockConfig); + +// Mock the cooldown module (shared with Gemini/GLM, see +// connectors/gemini/cooldown.js) rather than @upstash/redis directly -- +// groq/client.js only ever talks to cooldown.js's exported functions. +const mockIsModelCoolingDown = vi.fn(); +const mockSetModelCooldown = vi.fn(); +const mockParseRetryDelaySeconds = vi.fn((message) => { + const match = /retry in ([\d.]+)\s*s/i.exec(message || ""); + return match ? Math.ceil(parseFloat(match[1])) : null; +}); +vi.mock("../connectors/gemini/cooldown.js", () => ({ + isModelCoolingDown: mockIsModelCoolingDown, + setModelCooldown: mockSetModelCooldown, + parseRetryDelaySeconds: mockParseRetryDelaySeconds, +})); + +const originalFetch = global.fetch; +const originalEnv = { ...process.env }; + +describe("Groq Connector - Client and cascade logic (client.js)", () => { + let clientModule; + + beforeEach(async () => { + vi.resetModules(); + vi.clearAllMocks(); + mockConfig.GROQ_API_KEYS = ["key-a", "key-b"]; + mockConfig.GROQ_MODEL = "openai/gpt-oss-120b"; + mockConfig.GROQ_FALLBACK_MODELS = ["qwen/qwen3.6-27b"]; + mockIsModelCoolingDown.mockResolvedValue(false); + process.env = { ...originalEnv }; + clientModule = await import("../connectors/groq/client.js"); + }); + + afterEach(() => { + process.env = originalEnv; + global.fetch = originalFetch; + }); + + it("throws if GROQ_API_KEYS is empty", async () => { + mockConfig.GROQ_API_KEYS = []; + await expect(clientModule.groqChat([{ role: "user", content: "hi" }])).rejects.toThrow("GROQ_API_KEYS is not set"); + }); + + it("succeeds on the first key/model and returns the raw OpenAI choice", async () => { + global.fetch = vi.fn().mockResolvedValueOnce({ + ok: true, + text: async () => JSON.stringify({ + choices: [{ message: { role: "assistant", content: "Hello!" }, finish_reason: "stop" }], + }), + }); + + const choice = await clientModule.groqChat([{ role: "user", content: "hi" }]); + expect(choice.message.content).toBe("Hello!"); + expect(global.fetch).toHaveBeenCalledTimes(1); + const [url, init] = global.fetch.mock.calls[0]; + expect(url).toBe("https://api.groq.com/openai/v1/chat/completions"); + expect(init.headers.Authorization).toBe("Bearer key-a"); + expect(JSON.parse(init.body).model).toBe("openai/gpt-oss-120b"); + }); + + it("omits tools from the request body entirely when not provided", async () => { + global.fetch = vi.fn().mockResolvedValueOnce({ + ok: true, + text: async () => JSON.stringify({ choices: [{ message: { content: "ok" }, finish_reason: "stop" }] }), + }); + await clientModule.groqChat([{ role: "user", content: "hi" }]); + const body = JSON.parse(global.fetch.mock.calls[0][1].body); + expect(body.tools).toBeUndefined(); + }); + + it("sends max_tokens when maxOutputTokens is provided", async () => { + global.fetch = vi.fn().mockResolvedValueOnce({ + ok: true, + text: async () => JSON.stringify({ choices: [{ message: { content: "ok" }, finish_reason: "stop" }] }), + }); + await clientModule.groqChat([{ role: "user", content: "hi" }], { maxOutputTokens: 4096 }); + const body = JSON.parse(global.fetch.mock.calls[0][1].body); + expect(body.max_tokens).toBe(4096); + }); + + it("throws immediately on a non-retryable status (400)", async () => { + global.fetch = vi.fn().mockResolvedValueOnce({ + ok: false, + status: 400, + statusText: "Bad Request", + text: async () => JSON.stringify({ error: { message: "Invalid request" } }), + }); + await expect(clientModule.groqChat([{ role: "user", content: "hi" }])).rejects.toThrow("Groq API error (400): Invalid request"); + expect(global.fetch).toHaveBeenCalledTimes(1); + }); + + it("maps a network/abort failure to a transient error", async () => { + global.fetch = vi.fn().mockRejectedValue({ name: "AbortError", message: "aborted" }); + await expect(clientModule.groqChat([{ role: "user", content: "hi" }])).rejects.toThrow("Groq request timed out after 55000ms"); + }); + + it("cascades through the model list on 429/503 within the same key before switching keys", async () => { + global.fetch = vi.fn() + .mockResolvedValueOnce({ ok: false, status: 429, text: async () => JSON.stringify({ error: { message: "Rate limited. retry in 5s." } }) }) + .mockResolvedValueOnce({ ok: true, text: async () => JSON.stringify({ choices: [{ message: { content: "fallback model won" }, finish_reason: "stop" }] }) }); + + const choice = await clientModule.groqChat([{ role: "user", content: "hi" }]); + expect(choice.message.content).toBe("fallback model won"); + expect(global.fetch).toHaveBeenCalledTimes(2); + + const firstBody = JSON.parse(global.fetch.mock.calls[0][1].body); + expect(firstBody.model).toBe("openai/gpt-oss-120b"); + expect(global.fetch.mock.calls[0][1].headers.Authorization).toBe("Bearer key-a"); + const secondBody = JSON.parse(global.fetch.mock.calls[1][1].body); + expect(secondBody.model).toBe("qwen/qwen3.6-27b"); + expect(global.fetch.mock.calls[1][1].headers.Authorization).toBe("Bearer key-a"); + + expect(mockSetModelCooldown).toHaveBeenCalledWith("openai/gpt-oss-120b", 5, "groq:0"); + }); + + it("rotates to the next key on 401/403 without exhausting the model cascade on the bad key", async () => { + global.fetch = vi.fn() + .mockResolvedValueOnce({ ok: false, status: 401, text: async () => JSON.stringify({ error: { message: "Invalid API key" } }) }) + .mockResolvedValueOnce({ ok: true, text: async () => JSON.stringify({ choices: [{ message: { content: "key-b won" }, finish_reason: "stop" }] }) }); + + const choice = await clientModule.groqChat([{ role: "user", content: "hi" }]); + expect(choice.message.content).toBe("key-b won"); + expect(global.fetch).toHaveBeenCalledTimes(2); + expect(global.fetch.mock.calls[0][1].headers.Authorization).toBe("Bearer key-a"); + expect(global.fetch.mock.calls[1][1].headers.Authorization).toBe("Bearer key-b"); + const secondBody = JSON.parse(global.fetch.mock.calls[1][1].body); + expect(secondBody.model).toBe("openai/gpt-oss-120b"); + }); + + it("skips a model/key pair recorded as cooling down", async () => { + mockIsModelCoolingDown.mockImplementation(async (model, namespace) => model === "openai/gpt-oss-120b" && namespace === "groq:0"); + + global.fetch = vi.fn().mockResolvedValueOnce({ + ok: true, + text: async () => JSON.stringify({ choices: [{ message: { content: "used fallback model" }, finish_reason: "stop" }] }), + }); + + const choice = await clientModule.groqChat([{ role: "user", content: "hi" }]); + expect(choice.message.content).toBe("used fallback model"); + expect(global.fetch).toHaveBeenCalledTimes(1); + const body = JSON.parse(global.fetch.mock.calls[0][1].body); + expect(body.model).toBe("qwen/qwen3.6-27b"); + }); + + it("throws the last error once every key/model pair is exhausted", async () => { + global.fetch = vi.fn().mockResolvedValue({ ok: false, status: 503, statusText: "Service Unavailable", text: async () => "down" }); + await expect(clientModule.groqChat([{ role: "user", content: "hi" }])).rejects.toThrow("Groq API error (503)"); + // 2 keys x 2 models each = 4 attempts. + expect(global.fetch).toHaveBeenCalledTimes(4); + }); + + it("honors an explicitly requested model with no fallback cascade (but key rotation still applies)", async () => { + global.fetch = vi.fn().mockResolvedValue({ + ok: false, status: 429, text: async () => JSON.stringify({ error: { message: "Exhausted" } }), + }); + await expect( + clientModule.groqChat([{ role: "user", content: "hi" }], { model: "qwen/qwen3.6-27b" }) + ).rejects.toThrow(); + // With no fallback cascade, only 1 attempt per key -> 2 total for 2 keys. + expect(global.fetch).toHaveBeenCalledTimes(2); + for (const call of global.fetch.mock.calls) { + expect(JSON.parse(call[1].body).model).toBe("qwen/qwen3.6-27b"); + } + }); + + it("throws if Groq returns no choices", async () => { + global.fetch = vi.fn().mockResolvedValueOnce({ ok: true, text: async () => JSON.stringify({}) }); + await expect(clientModule.groqChat([{ role: "user", content: "hi" }])).rejects.toThrow("Groq returned no choices."); + }); +}); From 62579eebc4333221d776c661ff9b36bba171ec90 Mon Sep 17 00:00:00 2001 From: allocsys <225476909+allocsys@users.noreply.github.com> Date: Thu, 27 Aug 2026 03:27:04 +0330 Subject: [PATCH 08/17] Groq provider step 8b: update test/llm-router.test.js's mock path from ../connectors/glm/adapter.js to ../connectors/openai_shape/adapter.js (matching router.js's new import, see the router.js commit) and add groq dispatch coverage mirroring the existing glm tests -- dispatch/adapter-reuse, tools-omitted contract, explicit maxOutputTokens override, and failure propagation. --- test/llm-router.test.js | 73 ++++++++++++++++++++++++++++++++++++++--- 1 file changed, 68 insertions(+), 5 deletions(-) diff --git a/test/llm-router.test.js b/test/llm-router.test.js index 1e52bc8..e2d283d 100644 --- a/test/llm-router.test.js +++ b/test/llm-router.test.js @@ -1,10 +1,17 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; -import { GLM_DEFAULT_MAX_OUTPUT_TOKENS } from "../config.js"; - -// Mock both provider clients and the adapter so this test is purely about -// router.js's dispatch logic, not either provider's own wire format. +import { GLM_DEFAULT_MAX_OUTPUT_TOKENS, GROQ_DEFAULT_MAX_OUTPUT_TOKENS } from "../config.js"; + +// Mock all three provider clients and the shared adapter so this test is +// purely about router.js's dispatch logic, not any provider's own wire +// format. Mock path is ../connectors/openai_shape/adapter.js, NOT +// ../connectors/glm/adapter.js -- router.js imports the shared module +// directly since both glm and groq depend on it (see that file's header +// for the extraction history); glm/adapter.js still re-exports the same +// functions for any other importer, but router.js itself no longer goes +// through that re-export. const mockGeminiChat = vi.fn(); const mockGlmChat = vi.fn(); +const mockGroqChat = vi.fn(); const mockToOpenAIMessages = vi.fn((contents) => [{ role: "user", content: "adapted" }]); const mockToOpenAITools = vi.fn((tools) => (tools ? [{ type: "function", function: { name: "adapted_tool" } }] : undefined)); const mockFromOpenAIChoice = vi.fn((choice) => ({ content: { role: "model", parts: [{ text: "adapted answer" }] }, finishReason: choice?.finish_reason })); @@ -15,7 +22,10 @@ vi.mock("../connectors/gemini/client.js", () => ({ vi.mock("../connectors/glm/client.js", () => ({ glmChat: mockGlmChat, })); -vi.mock("../connectors/glm/adapter.js", () => ({ +vi.mock("../connectors/groq/client.js", () => ({ + groqChat: mockGroqChat, +})); +vi.mock("../connectors/openai_shape/adapter.js", () => ({ toOpenAIMessages: mockToOpenAIMessages, toOpenAITools: mockToOpenAITools, fromOpenAIChoice: mockFromOpenAIChoice, @@ -130,6 +140,59 @@ describe("connectors/llm/router.js — providerChat", () => { await expect(providerChat(contents, { provider: "glm" })).rejects.toThrow("OpenRouter API error (503): overloaded"); }); + it("dispatches to groq: adapts contents/tools in, adapts the choice back out, and NEVER touches geminiChat/glmChat", async () => { + mockGroqChat.mockResolvedValueOnce({ message: { content: "groq says hi" }, finish_reason: "stop" }); + const contents = [{ role: "user", parts: [{ text: "hello" }] }]; + const tools = [{ functionDeclarations: [{ name: "x" }] }]; + + const result = await providerChat(contents, { provider: "groq", tools, model: "openai/gpt-oss-120b" }); + + expect(mockToOpenAIMessages).toHaveBeenCalledWith(contents); + expect(mockToOpenAITools).toHaveBeenCalledWith(tools); + // Same "no maxOutputTokens passed -> fall back to the provider's own + // default" contract as glm, but against GROQ_DEFAULT_MAX_OUTPUT_TOKENS. + expect(mockGroqChat).toHaveBeenCalledWith( + [{ role: "user", content: "adapted" }], + { model: "openai/gpt-oss-120b", tools: [{ type: "function", function: { name: "adapted_tool" } }], maxOutputTokens: GROQ_DEFAULT_MAX_OUTPUT_TOKENS } + ); + expect(mockFromOpenAIChoice).toHaveBeenCalledWith({ message: { content: "groq says hi" }, finish_reason: "stop" }); + expect(mockGeminiChat).not.toHaveBeenCalled(); + expect(mockGlmChat).not.toHaveBeenCalled(); + expect(result).toEqual({ content: { role: "model", parts: [{ text: "adapted answer" }] }, finishReason: "stop" }); + }); + + it("omits tools from the groq path when none were passed (withholdTools contract)", async () => { + mockGroqChat.mockResolvedValueOnce({ message: { content: "done" }, finish_reason: "stop" }); + const contents = [{ role: "user", parts: [{ text: "hello" }] }]; + + await providerChat(contents, { provider: "groq" }); + + expect(mockToOpenAITools).not.toHaveBeenCalled(); + expect(mockGroqChat).toHaveBeenCalledWith( + [{ role: "user", content: "adapted" }], + { model: undefined, tools: undefined, maxOutputTokens: GROQ_DEFAULT_MAX_OUTPUT_TOKENS } + ); + }); + + it("honors an explicit maxOutputTokens on the groq path instead of the default", async () => { + mockGroqChat.mockResolvedValueOnce({ message: { content: "done" }, finish_reason: "stop" }); + const contents = [{ role: "user", parts: [{ text: "hello" }] }]; + + await providerChat(contents, { provider: "groq", maxOutputTokens: 2048 }); + + expect(mockGroqChat).toHaveBeenCalledWith( + [{ role: "user", content: "adapted" }], + { model: undefined, tools: undefined, maxOutputTokens: 2048 } + ); + }); + + it("propagates a groqChat failure without swallowing it", async () => { + mockGroqChat.mockRejectedValueOnce(new Error("Groq API error (503): overloaded")); + const contents = [{ role: "user", parts: [{ text: "hello" }] }]; + + await expect(providerChat(contents, { provider: "groq" })).rejects.toThrow("Groq API error (503): overloaded"); + }); + it("propagates a geminiChat failure without swallowing it", async () => { mockGeminiChat.mockRejectedValueOnce(new Error("Gemini API error (503): overloaded")); const contents = [{ role: "user", parts: [{ text: "hello" }] }]; From cd0ea33548a09513c3bed30358a127c906bb3f9f Mon Sep 17 00:00:00 2001 From: allocsys <225476909+allocsys@users.noreply.github.com> Date: Thu, 27 Aug 2026 03:27:14 +0330 Subject: [PATCH 09/17] Groq provider step 8c: add "groq" to agent-delegate-loop.test.js's provider parametrization. providerChat is fully mocked in this file, so runInvestigation's loop behaviors (stuck-loop guard, step-budget, checkpoint resume) are provider-invariant by construction -- this just confirms that invariant actually holds for a third provider value, not only two. --- test/agent-delegate-loop.test.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/agent-delegate-loop.test.js b/test/agent-delegate-loop.test.js index 2e83798..77e33cf 100644 --- a/test/agent-delegate-loop.test.js +++ b/test/agent-delegate-loop.test.js @@ -32,7 +32,7 @@ vi.mock("../connectors/github/client.js", () => ({ const originalEnv = { ...process.env }; -describe.each(["gemini", "glm"])("agent_delegate.js — runInvestigation (provider: %s)", (provider) => { +describe.each(["gemini", "glm", "groq"])("agent_delegate.js — runInvestigation (provider: %s)", (provider) => { let runInvestigation; beforeEach(async () => { From 2c2bdec5794f3aa7ccd7694657874710774a00e9 Mon Sep 17 00:00:00 2001 From: allocsys <225476909+allocsys@users.noreply.github.com> Date: Thu, 27 Aug 2026 03:27:30 +0330 Subject: [PATCH 10/17] Groq provider step 8d: rename test/glm-adapter.test.js -> test/openai-shape-adapter.test.js and repoint its import at the new shared connectors/openai_shape/adapter.js module, matching where the implementation actually lives now. Avoids running the same coverage twice under two names (glm/adapter.js is now just a re-export, so testing through it again would be redundant, not additional protection). --- test/{glm-adapter.test.js => openai-shape-adapter.test.js} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename test/{glm-adapter.test.js => openai-shape-adapter.test.js} (100%) diff --git a/test/glm-adapter.test.js b/test/openai-shape-adapter.test.js similarity index 100% rename from test/glm-adapter.test.js rename to test/openai-shape-adapter.test.js From 00a5edb8d1403d0a21610b1884453f8dbb5fd0e8 Mon Sep 17 00:00:00 2001 From: allocsys <225476909+allocsys@users.noreply.github.com> Date: Thu, 27 Aug 2026 03:27:39 +0330 Subject: [PATCH 11/17] Update the renamed test's import path and describe-block labels to reference connectors/openai_shape/adapter.js instead of connectors/glm/adapter.js -- no assertion logic changed, this is purely the rename's follow-through. --- test/openai-shape-adapter.test.js | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/test/openai-shape-adapter.test.js b/test/openai-shape-adapter.test.js index 8b5ccdb..fc11203 100644 --- a/test/openai-shape-adapter.test.js +++ b/test/openai-shape-adapter.test.js @@ -1,5 +1,5 @@ import { describe, it, expect } from "vitest"; -import { toOpenAIMessages, toOpenAITools, fromOpenAIChoice } from "../connectors/glm/adapter.js"; +import { toOpenAIMessages, toOpenAITools, fromOpenAIChoice } from "../connectors/openai_shape/adapter.js"; // This is the highest-risk file in the whole GLM provider switch (plan.md // step 3): a subtle role/shape mismatch here would silently corrupt @@ -11,7 +11,7 @@ import { toOpenAIMessages, toOpenAITools, fromOpenAIChoice } from "../connectors // functionResponse part with plain text in the SAME turn -- see // agent_delegate.js's header). -describe("glm/adapter.js — toOpenAIMessages", () => { +describe("openai_shape/adapter.js — toOpenAIMessages", () => { it("converts a task-only first turn to a plain user message", () => { const contents = [{ role: "user", parts: [{ text: "Task: investigate the thing" }] }]; const messages = toOpenAIMessages(contents); @@ -91,7 +91,7 @@ describe("glm/adapter.js — toOpenAIMessages", () => { }); }); -describe("glm/adapter.js — toOpenAITools", () => { +describe("openai_shape/adapter.js — toOpenAITools", () => { const FUNCTION_DECLARATIONS = [{ functionDeclarations: [ { name: "github_read_file", description: "Read a file.", parameters: { type: "object", properties: { path: { type: "string" } }, required: ["path"] } }, @@ -123,7 +123,7 @@ describe("glm/adapter.js — toOpenAITools", () => { }); }); -describe("glm/adapter.js — fromOpenAIChoice", () => { +describe("openai_shape/adapter.js — fromOpenAIChoice", () => { it("converts plain assistant text into a Gemini-shaped candidate with a text part", () => { const choice = { message: { role: "assistant", content: "The answer is 42." }, finish_reason: "stop" }; const candidate = fromOpenAIChoice(choice); @@ -186,7 +186,7 @@ describe("glm/adapter.js — fromOpenAIChoice", () => { }); }); -describe("glm/adapter.js — round trip: toOpenAIMessages -> (simulated model turn) -> fromOpenAIChoice", () => { +describe("openai_shape/adapter.js — round trip: toOpenAIMessages -> (simulated model turn) -> fromOpenAIChoice", () => { it("produces a candidate structurally identical to what geminiChat would have returned for an equivalent turn", () => { // Simulates one full step of agent_delegate.js's loop: an existing // Gemini-shaped conversation is adapted to OpenAI messages, a synthetic From 12c515be2c2f499f261d65b063a7d1d9d086d62b Mon Sep 17 00:00:00 2001 From: allocsys <225476909+allocsys@users.noreply.github.com> Date: Thu, 27 Aug 2026 03:31:07 +0330 Subject: [PATCH 12/17] Update plan.md status: Groq steps 1-8 implemented + green in CI; flag remaining work (live smoke test, docs) before rollout. Add missing "Model choice -- CORRECTED" note that config.js/commit 8bb2f4f already reference but this doc never actually recorded. --- plan.md | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/plan.md b/plan.md index c002768..4465e7d 100644 --- a/plan.md +++ b/plan.md @@ -2,11 +2,16 @@ **STATUS (2026-08-27): GLM/OpenRouter implementation shipped and deployed, but currently non-functional (see "Current status" -- account has no -credit and none is being added). A third provider, Groq, is queued to -replace GLM as the practical free-tier alternative to Gemini -- see -"Groq provider addition" below for the sequenced plan. GLM code stays in -place (not being ripped out) in case OpenRouter credit is ever added -later.** +credit and none is being added). Groq has been added as the practical +free-tier alternative to Gemini -- steps 1-8 of "Groq provider addition" +below (config, client, shared adapter extraction, router wiring, +checkpoint/cooldown reuse, tool schema, tests) are implemented and green +in CI as of commit `00a5edb`. STILL OUTSTANDING: step 9 (live smoke test +against a real Groq account -- nothing here has been run against Groq's +actual API yet, only mocked), step 10 (rollout confirmation), and doc +updates -- README.md/docs/API_KEYS.md/docs/env.html still don't mention +Groq at all as of this STATUS line. GLM code stays in place (not being +ripped out) in case OpenRouter credit is ever added later.** ## Why From fc6cf5c6179dc3dc52ea9bdfcc66b1e67e7b8b8d Mon Sep 17 00:00:00 2001 From: allocsys <225476909+allocsys@users.noreply.github.com> Date: Thu, 27 Aug 2026 03:31:20 +0330 Subject: [PATCH 13/17] Add the "Model choice -- CORRECTED" note that config.js's comment (added in commit 8bb2f4f) and that commit's own message already reference, but this plan doc never actually recorded -- the qwen/gpt-oss ordering below was superseded before implementation and the shipped code uses the opposite ordering. --- plan.md | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/plan.md b/plan.md index 4465e7d..05bf219 100644 --- a/plan.md +++ b/plan.md @@ -246,6 +246,24 @@ deprecation page):** - Verify both slugs live at https://console.groq.com/docs/models immediately before implementation, not from this plan alone. +**Model choice -- CORRECTED (2026-08-27, before implementation):** the +ordering above was written before checking each slug's own catalog listing +type, not just its churn history. Groq's model catalog +(https://console.groq.com/docs/models) classifies `qwen/qwen3.6-27b` as a +**preview** model -- "intended for evaluation purposes only... may be +discontinued at short notice" -- despite scoring highest on Groq's own +intelligence ranking, while `openai/gpt-oss-120b` is a **production** +model. `delegate_agent` is a persistent, unattended provider option, not a +one-off benchmark run, so availability stability outweighs a benchmark +edge here. **Swap the ordering above**: `GROQ_MODEL` defaults to +`openai/gpt-oss-120b` (production, primary) and `GROQ_FALLBACK_MODELS` +defaults to `qwen/qwen3.6-27b` (preview, fallback only -- stronger when +available, but not to be relied on as the primary path). This is what +config.js actually ships (see its GROQ_MODEL/GROQ_FALLBACK_MODELS +comments) -- do not revert to the original ordering above without +re-checking https://console.groq.com/docs/models for whether either +model's classification has changed. + **Sequenced steps:** 1. **Config (`config.js`):** add `GROQ_API_KEYS` (comma-separated, plural From 393e8d3150408f432d06faa5c58c0a0d6373d6ce Mon Sep 17 00:00:00 2001 From: allocsys <225476909+allocsys@users.noreply.github.com> Date: Thu, 27 Aug 2026 03:31:37 +0330 Subject: [PATCH 14/17] Mark sequenced steps 1-8 done (implemented, tests green in CI) and flag 9-10 as the remaining work, so the plan doc matches the branch's actual state instead of reading as "not started". --- plan.md | 38 ++++++++++++++++++++++++++------------ 1 file changed, 26 insertions(+), 12 deletions(-) diff --git a/plan.md b/plan.md index 05bf219..0588dba 100644 --- a/plan.md +++ b/plan.md @@ -264,9 +264,10 @@ comments) -- do not revert to the original ordering above without re-checking https://console.groq.com/docs/models for whether either model's classification has changed. -**Sequenced steps:** +**Sequenced steps (steps 1-8 DONE as of commit `00a5edb`, all green in CI; +steps 9-10 still outstanding -- see updated STATUS line above):** -1. **Config (`config.js`):** add `GROQ_API_KEYS` (comma-separated, plural +1. **DONE. Config (`config.js`):** add `GROQ_API_KEYS` (comma-separated, plural -- same rotation pattern as `OPENROUTER_API_KEYS`/`EXA_API_KEYS`), `GROQ_API` endpoint, `GROQ_MODEL`, `GROQ_FALLBACK_MODELS`, `GROQ_REQUEST_TIMEOUT_MS`. Also add `GROQ_DEFAULT_MAX_OUTPUT_TOKENS` @@ -275,12 +276,12 @@ model's classification has changed. default; don't repeat that discovery-by-failure cycle for Groq. Pick a conservative starting value (e.g. 4096-8192) and confirm Groq's actual per-model max-output limits from its docs before the first live call. -2. **Client (`connectors/groq/client.js`):** model closely on +2. **DONE. Client (`connectors/groq/client.js`):** model closely on `connectors/glm/client.js` -- outer cascade over `GROQ_API_KEYS` (401/403/429), inner cascade over `GROQ_MODEL` + `GROQ_FALLBACK_MODELS` (429/503/transient). Groq's chat-completions endpoint is OpenAI-shaped like OpenRouter's, so this should be a close port, not a redesign. -3. **Adapter -- decide before writing, don't default to copy-paste:** +3. **DONE (chose option (a), the shared module):** ~~Adapter -- decide before writing, don't default to copy-paste:~~ `connectors/glm/adapter.js` (`toOpenAIMessages`/`toOpenAITools`/ `fromOpenAIChoice`) is pure OpenAI-shape translation with nothing OpenRouter-specific in it. Either (a) extract it to a shared @@ -292,29 +293,38 @@ model's classification has changed. translation logic is exactly the kind of drift this codebase's other shared-harness fixes (e.g. `validateFunctionArgs`) have had to unwind after the fact. -4. **Router (`connectors/llm/router.js`):** add a `groq` branch alongside +4. **DONE. Router (`connectors/llm/router.js`):** add a `groq` branch alongside `gemini`/`glm` in `providerChat`, applying `GROQ_DEFAULT_MAX_OUTPUT_TOKENS` the same way the `glm` branch applies its own default (only when the caller doesn't pass an explicit `maxOutputTokens`). -5. **Cooldown (`connectors/gemini/cooldown.js`):** already takes a +5. **DONE (no changes needed -- confirmed already provider-agnostic). Cooldown (`connectors/gemini/cooldown.js`):** already takes a `namespace` param -- have Groq pass `groq:${keyIndex}` for its own per-(model,key) cooldown tracking, same pattern GLM uses. -6. **Checkpoint (`connectors/gemini/agent_checkpoint.js`):** confirm the +6. **DONE (confirmed by direct read, genuinely provider-agnostic). Checkpoint (`connectors/gemini/agent_checkpoint.js`):** confirm the provider/model/maxOutputTokens restore-on-resume logic is genuinely provider-agnostic (stores whatever string it's given) before assuming a third provider value "just works" -- verify by reading the file, not by inference from the GLM integration having worked. -7. **Tool schema (`connectors/gemini/agent_tools.js`):** the `provider` +7. **DONE. Tool schema (`connectors/gemini/agent_tools.js`):** the `provider` arg is very likely a zod enum -- if so it needs `"groq"` added explicitly, since zod enums don't silently accept unlisted values. Check this before assuming the router-level change alone is sufficient. -8. **Tests:** mirror the GLM test set -- +8. **DONE. Tests:** mirror the GLM test set -- `test/groq-client.test.js`, and a shared/adapter test if step 3 goes with option (a); extend `test/llm-router.test.js`'s dispatch test to cover the `groq` branch; extend `test/agent-delegate-loop.test.js`'s provider parametrization to include `groq` as a third case. -9. **Live smoke test, sequenced to catch GLM's failure modes early rather - than late:** run the same three-stage test that surfaced GLM's +9. **NOT DONE -- still the main gap before this can be trusted with real + traffic.** Everything in steps 1-8 has only been exercised against + mocked `fetch`/`providerChat` calls (see test/groq-client.test.js, + test/llm-router.test.js, test/agent-delegate-loop.test.js) -- nothing + has actually hit `api.groq.com` yet, so the `max_tokens` vs + `max_completion_tokens` question flagged in config.js's + `GROQ_DEFAULT_MAX_OUTPUT_TOKENS` comment, and whether the "not + balance-gated" assumption holds, are both still open. Requires a real + `GROQ_API_KEYS` value to run -- can't be completed from this branch + alone. **Live smoke test, sequenced to catch GLM's failure modes early + rather than late:** run the same three-stage test that surfaced GLM's problems -- (a) a tool-using multi-step task at the default output cap, (b) the same with a reduced `maxOutputTokens` to check for a prompt- token-side cap independent of the completion cap, (c) a minimal @@ -323,7 +333,11 @@ model's classification has changed. assumption above -- if any of the three reproduce a GLM-style 402/404, that assumption is wrong and needs documenting here before going further. -10. **Rollout:** ship behind `provider: "groq"`, opt-in only -- default +10. **Code-wise DONE, but gated on step 9:** `provider: "groq"` is already + wired as opt-in-only with `DEFAULT_LLM_PROVIDER` unchanged (see step 7) -- + the remaining rollout question is whether to treat it as trustworthy for + real tasks before step 9's live verification, same caution GLM's rollout + used. **Rollout:** ship behind `provider: "groq"`, opt-in only -- default stays `gemini` (`DEFAULT_LLM_PROVIDER` unchanged). Do not remove or disable the `glm`/OpenRouter code path in the process -- it stays available and will resume working immediately if OpenRouter credit is From 1e735fdeb82974b9c73097e63df224f6c5a3e607 Mon Sep 17 00:00:00 2001 From: allocsys <225476909+allocsys@users.noreply.github.com> Date: Thu, 27 Aug 2026 03:31:45 +0330 Subject: [PATCH 15/17] Document GROQ_API_KEYS (missed in the Groq provider steps 1-8 implementation pass -- see plan.md STATUS). --- docs/API_KEYS.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/docs/API_KEYS.md b/docs/API_KEYS.md index 221b3f2..b43ba05 100644 --- a/docs/API_KEYS.md +++ b/docs/API_KEYS.md @@ -34,6 +34,10 @@ None of these providers support auto-injecting the key back into our env — you [![Create OpenRouter API Key](https://img.shields.io/badge/Create-OpenRouter_Key-6467F2?style=for-the-badge)](https://openrouter.ai/keys) > Comma-separated list — same reasoning as `EXA_API_KEYS` above (rate-limit headroom + account isolation). Backs the optional `provider: "glm"` mode of `delegate_agent` (GLM via OpenRouter) — leave unset to stick with the default Gemini provider. +### Groq — `GROQ_API_KEYS` +[![Create Groq API Key](https://img.shields.io/badge/Create-Groq_API_Key-F55036?style=for-the-badge)](https://console.groq.com/keys) +> Comma-separated list — same reasoning as `EXA_API_KEYS`/`OPENROUTER_API_KEYS` above. Backs the optional `provider: "groq"` mode of `delegate_agent` — free-tier request/token-rate-limited rather than credit-balance-gated (unlike OpenRouter/GLM), so it's the practical free alternative while `provider: "glm"` is blocked on account credit. Leave unset to stick with the default Gemini provider. + ### Context7 — `CONTEXT7_API_KEY` (optional) Works unauthenticated at low rate limits — only provision this if you're hitting limits. From 21e7f638dde9e7942daa94217aa07e7939f379c4 Mon Sep 17 00:00:00 2001 From: allocsys <225476909+allocsys@users.noreply.github.com> Date: Thu, 27 Aug 2026 03:32:03 +0330 Subject: [PATCH 16/17] Add a GROQ_API_KEYS card to the env bundler (missed in the Groq provider implementation pass) -- mirrors the OPENROUTER_API_KEYS card's multi-key pattern, and updates the fill-count total from 14 to 15. --- docs/env.html | 35 +++++++++++++++++++++++++++++++++-- 1 file changed, 33 insertions(+), 2 deletions(-) diff --git a/docs/env.html b/docs/env.html index 6793ed0..5fe098c 100644 --- a/docs/env.html +++ b/docs/env.html @@ -164,7 +164,7 @@

Env Bundler

No data leaves the browser / nothing is uploaded anywhere, it's purely local assembly.

Click each badge to go generate that provider's key, paste the value into the box, then copy the single bundled .env block at the bottom into Vercel/Render's environment variables.

-
0 of 14 filled
+
0 of 15 filled

GITHUB_TOKEN

@@ -263,6 +263,19 @@

OPENROUTER_API_KEYS

comma-separated, optional -- only needed for delegate_agent's provider: "glm" mode

+
+
+

GROQ_API_KEYS

+ Generate API Key +
+
+ + + +
+

comma-separated, optional -- only needed for delegate_agent's provider: "groq" mode

+
+

CONTEXT7_API_KEY

@@ -337,6 +350,9 @@

Bundle

const openrouterApiKeyInput = document.getElementById('openrouter-api-key-1'); const addOpenrouterApiKeyButton = document.getElementById('add-openrouter-api-key'); const openrouterApiKeysContainer = document.getElementById('openrouter-api-keys-container'); + const groqApiKeyInput = document.getElementById('groq-api-key-1'); + const addGroqApiKeyButton = document.getElementById('add-groq-api-key'); + const groqApiKeysContainer = document.getElementById('groq-api-keys-container'); const context7ApiKeyInput = document.getElementById('context7-api-key'); const upstashRedisRestUrlInput = document.getElementById('upstash-redis-rest-url'); const upstashRedisRestTokenInput = document.getElementById('upstash-redis-rest-token'); @@ -349,6 +365,7 @@

Bundle

let exaApiKeys = []; let openrouterApiKeys = []; + let groqApiKeys = []; addExaApiKeyButton.addEventListener('click', () => { const newInput = document.createElement('input'); @@ -367,6 +384,15 @@

Bundle

openrouterApiKeys.push(newInput); }); + addGroqApiKeyButton.addEventListener('click', () => { + const newInput = document.createElement('input'); + newInput.type = 'text'; + newInput.placeholder = 'Paste API key value'; + newInput.addEventListener('input', updateBundle); + groqApiKeysContainer.appendChild(newInput); + groqApiKeys.push(newInput); + }); + generateMcpSharedKeyButton.addEventListener('click', () => { const array = new Uint8Array(32); crypto.getRandomValues(array); @@ -391,6 +417,10 @@

Bundle

const openrouterApiKeysValues = [openrouterApiKeyInput.value, ...openrouterApiKeys.map(input => input.value)].filter(value => value); bundle.push(`OPENROUTER_API_KEYS=${openrouterApiKeysValues.join(',')}`); } + if (groqApiKeyInput.value || groqApiKeys.some(input => input.value)) { + const groqApiKeysValues = [groqApiKeyInput.value, ...groqApiKeys.map(input => input.value)].filter(value => value); + bundle.push(`GROQ_API_KEYS=${groqApiKeysValues.join(',')}`); + } if (context7ApiKeyInput.value) bundle.push(`CONTEXT7_API_KEY=${context7ApiKeyInput.value}`); if (upstashRedisRestUrlInput.value) bundle.push(`UPSTASH_REDIS_REST_URL=${upstashRedisRestUrlInput.value}`); if (upstashRedisRestTokenInput.value) bundle.push(`UPSTASH_REDIS_REST_TOKEN=${upstashRedisRestTokenInput.value}`); @@ -400,7 +430,7 @@

Bundle

if (githubAppPrivateKeyInput.value) bundle.push(`GITHUB_APP_PRIVATE_KEY="${githubAppPrivateKeyInput.value.replace(/\n/g, '\\n')}"`); bundleOutput.textContent = bundle.join('\n'); const filled = bundle.length; - progress.textContent = `${filled} of 14 filled`; + progress.textContent = `${filled} of 15 filled`; } githubTokenInput.addEventListener('input', updateBundle); @@ -413,6 +443,7 @@

Bundle

exaApiKeyInput.addEventListener('input', updateBundle); exaApiKeys.forEach(input => input.addEventListener('input', updateBundle)); openrouterApiKeyInput.addEventListener('input', updateBundle); + groqApiKeyInput.addEventListener('input', updateBundle); context7ApiKeyInput.addEventListener('input', updateBundle); upstashRedisRestUrlInput.addEventListener('input', updateBundle); upstashRedisRestTokenInput.addEventListener('input', updateBundle); From 59ceae4ece71165655b15a0408498f630fbf97e2 Mon Sep 17 00:00:00 2001 From: allocsys <225476909+allocsys@users.noreply.github.com> Date: Thu, 27 Aug 2026 03:32:24 +0330 Subject: [PATCH 17/17] Document the Groq provider in README.md (missed in the Groq provider implementation pass, see plan.md STATUS): mention it alongside Gemini/GLM in delegate_agent's description, add a key badge, and add GROQ_* rows to the config table. --- README.md | 38 ++++++++++++++++++++++++-------------- 1 file changed, 24 insertions(+), 14 deletions(-) diff --git a/README.md b/README.md index 9ce139a..8491b8c 100644 --- a/README.md +++ b/README.md @@ -81,6 +81,7 @@ Need connector tokens first? **[→ Get API keys](./docs/API_KEYS.md)** — one- [![Jules Key](https://img.shields.io/badge/Jules-API_Key-4285F4?style=flat-square&logo=googlegemini)](https://jules.google.com/settings#api) [![Exa Key](https://img.shields.io/badge/Exa-API_Key-000000?style=flat-square)](https://dashboard.exa.ai/api-keys) [![OpenRouter Key](https://img.shields.io/badge/OpenRouter-API_Key-6467F2?style=flat-square)](https://openrouter.ai/keys) +[![Groq Key](https://img.shields.io/badge/Groq-API_Key-F55036?style=flat-square)](https://console.groq.com/keys) [![Upstash Redis](https://img.shields.io/badge/Upstash-Redis-00E9A3?style=flat-square)](https://console.upstash.com/redis) [![Generate Secret](https://img.shields.io/badge/Generate-MCP__SHARED__KEY-333333?style=flat-square)](https://generate-secret.vercel.app/32) @@ -176,20 +177,25 @@ any in the first place. `delegate_agent` — hand an open-ended, multi-step, read-only investigation (e.g. "why is CI failing on PR #42", "summarize what changed in this repo over -the last week") to Gemini (default) or GLM instead of making 5-10 separate -manual tool calls. The model runs its own loop server-side across GitHub, -Cloudflare, and Notion (bounded by `max_steps`, default 6, hard cap 20) and -returns one synthesized answer. Falls through an ordered model cascade -(`GEMINI_MODEL` → `GEMINI_FALLBACK_MODELS`, or for GLM `GLM_MODEL` → -`GLM_FALLBACK_MODELS` across every key in `OPENROUTER_API_KEYS`) on rate -limits, with Redis-backed per-model cooldown so already-limited models are -skipped rather than retried. An explicit `provider: "gemini" | "glm"` arg -picks which one backs a given call (default: `DEFAULT_LLM_PROVIDER`, itself -defaulting to `"gemini"`) — the two are interchangeable in capability, not -just cost/speed; try `"glm"` if Gemini's output has been unreliable for a -given task. Ignored on a resume (the provider that started the run is -always reused, so a checkpointed conversation can't be corrupted by -resuming it on a different provider's wire format). +the last week") to Gemini (default), GLM, or Groq instead of making 5-10 +separate manual tool calls. The model runs its own loop server-side across +GitHub, Cloudflare, and Notion (bounded by `max_steps`, default 6, hard cap +20) and returns one synthesized answer. Falls through an ordered model +cascade (`GEMINI_MODEL` → `GEMINI_FALLBACK_MODELS`, `GLM_MODEL` → +`GLM_FALLBACK_MODELS` across every key in `OPENROUTER_API_KEYS`, or +`GROQ_MODEL` → `GROQ_FALLBACK_MODELS` across every key in `GROQ_API_KEYS`) +on rate limits, with Redis-backed per-model cooldown so already-limited +models are skipped rather than retried. An explicit +`provider: "gemini" | "glm" | "groq"` arg picks which one backs a given +call (default: `DEFAULT_LLM_PROVIDER`, itself defaulting to `"gemini"`) — +all three are interchangeable in capability, not just cost/speed; try +`"glm"` or `"groq"` if Gemini's output has been unreliable for a given +task. GLM (via OpenRouter) is currently non-functional on a zero-credit +account — see `docs/API_KEYS.md` — so Groq is the practical free-tier +alternative to Gemini for now (request/token-rate-limited rather than +credit-balance-gated, no card required). Ignored on a resume (the provider +that started the run is always reused, so a checkpointed conversation +can't be corrupted by resuming it on a different provider's wire format). `delegate_research` — web research, in one of two mutually-exclusive modes selected by which args are passed: @@ -299,6 +305,10 @@ All tokens are optional independently — a connector's tools fail at call time | `GLM_MODEL` | Primary GLM model (via OpenRouter) for `provider: "glm"` delegation (default `z-ai/glm-4.6`) | | `GLM_FALLBACK_MODELS` | Comma-separated fallback model list used on 429s, cascaded per `OPENROUTER_API_KEYS` key (default `z-ai/glm-4.5-air:free`) | | `GLM_REQUEST_TIMEOUT_MS` | Defensive ceiling on a single GLM/OpenRouter call (default `55000`) | +| `GROQ_API_KEYS` | Comma-separated Groq API key(s) — required for `delegate_agent`'s `provider: "groq"` mode, unused otherwise; free-tier is request/token-rate-limited, not credit-balance-gated like OpenRouter/GLM | +| `GROQ_MODEL` | Primary Groq model for `provider: "groq"` delegation (default `openai/gpt-oss-120b`, a production model) | +| `GROQ_FALLBACK_MODELS` | Comma-separated fallback model list used on 429s, cascaded per `GROQ_API_KEYS` key (default `qwen/qwen3.6-27b` — stronger on benchmarks but a Groq **preview** model, kept as fallback rather than primary for availability reasons) | +| `GROQ_REQUEST_TIMEOUT_MS` | Defensive ceiling on a single Groq call (default `55000`) | | `DEFAULT_LLM_PROVIDER` | Which provider `delegate_agent` uses when a call omits `provider` (default `gemini`) | | `UPSTASH_REDIS_REST_URL` + `UPSTASH_REDIS_REST_TOKEN` (or `KV_REST_API_URL` + `KV_REST_API_TOKEN`) | Optional — persists per-model rate-limit cooldowns and `delegate_agent` resume checkpoints across invocations; fails open if neither pair is set. Either naming works — the raw Upstash Marketplace integration names them `UPSTASH_REDIS_REST_*`, Vercel's own "KV" product (also Upstash-backed) names them `KV_REST_API_*`. | | `DEFAULT_OWNER` | Default GitHub owner when omitted from a call (defaults to `allocsys`) |