Skip to main content

OpenRouter Multi-Turn Chat Completion Agent

This integration calls OpenRouter's chat completions API and preserves the message history across turns within an evaluation session, so the model sees the full conversation. It is the conversational counterpart to the OpenRouter Chat Completion Agent.

Required secrets

SecretWhat it is
OPENROUTER_API_KEYAPI key for calling OpenRouter chat completions.

Where to get the credentials

Generate an API key in your OpenRouter account settings, then store it as a project secret named OPENROUTER_API_KEY.

Turn support

Multi-turn. Conversation history is preserved across turns within a session, so it suits multi-turn evaluation.

Limitations and notes

  • The model and system prompt are set in the copied agent code. Edit the agent if you need to change them.
  • Calls are billed to your own OpenRouter account, subject to its quotas and rate limits.

How the agent works

The copied agent is a single JavaScript function. This walkthrough follows the real code so you can adapt the same pattern to point at a different service.

The entry point is process(input), where input is the text for the current turn. Configuration and input problems throw (so they are not scored as model output), while a genuine reply is returned:

export default async function process(input) {
const apiKey = String(env.OPENROUTER_API_KEY ?? "").trim();
if (!apiKey) {
throw new Error("missing OPENROUTER_API_KEY");
}
const parsed = parseInput(input);
if (!parsed.text) {
throw new Error("empty input text");
}
// ...

The secret is read from the ambient env object by key — env.OPENROUTER_API_KEY — and trimmed. See Required secrets for the key you must declare.

parseInput accepts either plain text or a JSON object. A JSON object lets a turn override the prompt, model, system prompt, and optional temperature/seed:

if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
const temperature = Number(parsed.temperature);
const seed = Number(parsed.seed);
return {
text: String(parsed.text ?? parsed.prompt ?? parsed.input ?? "").trim(),
model: String(parsed.model ?? DEFAULT_MODEL).trim() || DEFAULT_MODEL,
system: String(
parsed.system ?? parsed.instructions ?? DEFAULT_SYSTEM_PROMPT,
).trim(),
// ...
};
}

Preserving conversation state across turns

The message history lives in a module-level messages array:

let messages = [];

This is what makes the agent multi-turn. Each conversation runs in its own runtime, so messages starts empty per conversation, then accumulates across turns. buildNextMessages appends to the existing history — adding the system message only on the first turn — so the model sees the full conversation:

const buildNextMessages = ({ text, system }) => {
const nextMessages = [...messages];
const systemPrompt = String(system ?? DEFAULT_SYSTEM_PROMPT).trim();
if (nextMessages.length === 0 && systemPrompt) {
nextMessages.push({ role: "system", content: systemPrompt });
}
nextMessages.push({ role: "user", content: text });
return nextMessages;
};

State is committed only after a successful turn, so a thrown error leaves the conversation intact for the platform's retry:

messages = [...nextMessages, { role: "assistant", content: outputText }];
return outputText;

Request, timeout, and response parsing

The body is { model, messages }, adding temperature/seed only when valid. The request uses an AbortController so a hung call surfaces as a clean, retryable error rather than a hard sandbox timeout:

const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS);
let response;
try {
response = await fetch(ENDPOINT, {
method: "POST",
headers: {
authorization: `Bearer ${apiKey}`,
"content-type": "application/json",
"HTTP-Referer": ATTRIBUTION_REFERER,
"X-Title": ATTRIBUTION_TITLE,
},
body: JSON.stringify(body),
signal: controller.signal,
});
} catch (error) {
throw new Error(
`openrouter_request_failed: ${error instanceof Error ? error.message : String(error)}`,
);
} finally {
clearTimeout(timer);
}

extractResponseText reads the first choice's message.content. It returns null only when the response is structurally malformed (no usable choice) — an empty string is treated as a legitimate model output, not an error:

const extractResponseText = (payload) => {
const choices = Array.isArray(payload?.choices) ? payload.choices : null;
if (!choices || choices.length === 0) {
return null;
}
for (const choice of choices) {
const content = choice?.message?.content;
if (typeof content === "string") {
return content.trim();
}
// ...
}
// ...
return null;
};

The error contract is the opposite of the single-turn agent: infrastructure and configuration faults — missing key, non-2xx status, unparseable or malformed response — throw, while a genuine reply (including an empty one) is returned for scoring:

if (!response.ok) {
throw new Error(
`openrouter_request_failed: ${response.status}: ${extractErrorMessage(payload, responseText)}`,
);
}

Adapting it for your own agent

To repoint this skeleton at a different service, change a handful of things:

  • ENDPOINT — the URL you POST to.
  • The auth header (and the attribution headers, which are OpenRouter-specific).
  • The request body shape — { model, messages } here; match your provider's schema.
  • extractResponseText — read the field your provider returns the text in.
  • The secret key read from env (and its entry in Required secrets).
  • Keep the module-level messages array if you want multi-turn behavior, and keep committing it only after a successful turn.

Set it up

  1. Copy this integration from the registry — see Registry integrations.
  2. Create the project secret above — see Project secrets.
  3. Preview the agent to confirm it works.