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
| Secret | What it is |
|---|---|
OPENROUTER_API_KEY | API 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 youPOSTto.- 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
messagesarray if you want multi-turn behavior, and keep committing it only after a successful turn.
Set it up
- Copy this integration from the registry — see Registry integrations.
- Create the project secret above — see Project secrets.
- Preview the agent to confirm it works.