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`) | 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 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 }) => { 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"; 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, ... } +} 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 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, + }; +} 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. 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); diff --git a/plan.md b/plan.md index c002768..0588dba 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 @@ -241,9 +246,28 @@ deprecation page):** - Verify both slugs live at https://console.groq.com/docs/models immediately before implementation, not from this plan alone. -**Sequenced steps:** +**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. -1. **Config (`config.js`):** add `GROQ_API_KEYS` (comma-separated, plural +**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. **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` @@ -252,12 +276,12 @@ deprecation page):** 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 @@ -269,29 +293,38 @@ deprecation page):** 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 @@ -300,7 +333,11 @@ deprecation page):** 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 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 () => { 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."); + }); +}); 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" }] }]; diff --git a/test/glm-adapter.test.js b/test/openai-shape-adapter.test.js similarity index 97% rename from test/glm-adapter.test.js rename to test/openai-shape-adapter.test.js index 8b5ccdb..fc11203 100644 --- a/test/glm-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