-
#109
17418f7Thanks @devin-ai-integration! - Add opt-in replay compaction and terminal response-event handling for streamed model calls.import { callModel } from "@openrouter/agent"; const result = callModel(client, { model: "openai/gpt-4o", input: "Summarize this document.", // Retain only the history needed by currently attached consumers. streamReplay: "active-consumers", });
-
#94
c610b6eThanks @LukasParke! - Fix two ways the tool-approval gate could be bypassed.allowFinalResponseexecuted pending tool calls with no approval check. When astopWhencondition halted the loop on a turn that still carried tool calls, the final-response path ran those calls directly — skipping the approval gate the normal loop applies on every round. A tool markedrequireApproval: true(or gated by a predicate) would execute unguarded, and because thePermissionRequesthook's deny bookkeeping lives inside the approval check, hook-baseddenynever fired on this path either. That path now runs the same check as the in-loop call sites, so the run pauses withstatus: 'awaiting_approval'and the gated calls onpendingToolCallsinstead of executing them.Function-based
requireApprovalreceived unvalidated arguments. Tool-level and call-level predicates were called with the raw JSON-parsed tool arguments, whileexecutereceives the arguments after the tool's ZodinputSchemaruns. Any default, coercion, or transform in the schema made them disagree — e.g. withinputSchema: z.object({ dangerous: z.boolean().default(true) }), a model emitting{}showed a predicatedangerous: undefined(no approval required) and then executed withdangerous: true. Predicates now see a parsed copy, so they decide on exactly whatexecutewill receive without mutating the original executable call or parsing transformed output a second time.PreToolUsenow runs before every auto-resolvable call is partitioned, so approval hooks and persisted pending calls see its effective arguments. Pending calls record an additive marker when preparation ran, preventing a resumedModelResultfrom applying the hook twice while legacy state without the marker retains its prior behavior. Call-level checks remain unconditional and receive raw arguments when parsing fails; tool-level checks fail closed when schema parsing fails because a hook may later repair the input.Duplicate approval prompts for the same tool call. The approval gate could run more than once over the same response — e.g. the pre-loop check plus the post-loop
allowFinalResponsegate when a stop condition fired on the first iteration — re-emitting thePermissionRequesthook and re-runningrequireApprovalpredicates for calls that were already resolved. Each call occurrence in a response is now gated at most once per run, including responses containing duplicate call IDs and arguments.import { z } from "zod/v4"; import { tool, type PendingToolCall } from "@openrouter/agent"; const deploy = tool({ name: "deploy", inputSchema: z.object({ environment: z.enum(["staging", "production"]).default("production"), }), requireApproval: ({ environment }) => environment === "production", execute: async ({ environment }) => deployEnvironment(environment), }); // `requireApproval` sees the normalized default: { environment: 'production' }. // Persist this additive marker when PreToolUse already produced effective args. const pending: PendingToolCall<typeof deploy> = { id: "call_deploy", name: "deploy", arguments: { environment: "production" }, preToolUseApplied: true, };
-
#112
8a922b5Thanks @LukasParke! - Add a./reusable-streamsubpath export so consumers can importReusableReadableStreamdirectly (@openrouter/agent/reusable-stream) instead of going through the root barrel or patching the package. Mirrors the existing./tool-event-broadcasterentry; both replay classes are the units consumers need when asserting stream-retention behavior against the published package. -
#111
7416059Thanks @w0nche0l! - Remove the runtime@openrouter/sdk/modelsimport fromturn-context.ts. The namespace import existed only to readEasyInputMessageRoleUser.User(the string'user'), but it made every consumer that statically imports@openrouter/agent/tool(viaagent-tool→conversation-state→turn-context) evaluate the entire Speakeasy models barrel — hundreds of modules of top-level Zod schema construction — at module load. On Cloudflare Workers this added ~200ms of startup CPU per worker and pushed large workers past the 1s script-validation ceiling (error 10021).The import is now type-only (erased at compile time) and the role literal is inlined, keeping behavior identical. A new unit test walks the static runtime import graph of the hot subpaths (
/tool,/tool-types,/stop-conditions) and fails if any of them ever reaches@openrouter/sdkat runtime again.
-
#102
787cbf8Thanks @LukasParke! - Add the full MCP integration under the canonical@openrouter/agent/mcpsubpath.@modelcontextprotocol/clientis an optional peer, so base agent installations and imports do not install or load MCP support. The existing@openrouter/mcppackage remains as a compatibility facade and now re-exports the canonical agent subpaths.import { callModel, OpenRouter } from "@openrouter/agent"; import { createMCPTools } from "@openrouter/agent/mcp"; const mcp = await createMCPTools({ url: "https://mcp.example.com/mcp" }); const result = callModel(new OpenRouter(), { model: "openai/gpt-4o-mini", input: "Use the remote tools.", tools: mcp.tools, });
Install
@modelcontextprotocol/clientalongside@openrouter/agentwhen using/mcp. The SDK is loaded lazily, so importing the base agent or the MCP entry point does not require the peer; the first MCP connection attempt without it throws an actionableMCPMissingPeerDependencyError.Existing
@openrouter/mcpimports continue to work as tooling-visible deprecated migration facades, but new code should prefer@openrouter/agent/mcp. The facade would only be removed in a future breaking release after migration notice.The
@openrouter/mcpfacade continues to install@modelcontextprotocol/clienttransitively for backward compatibility; only direct@openrouter/agent/mcpusers need to add the optional peer explicitly. -
#31
8d2ed61Thanks @mattapperson! - Add@openrouter/agent/tool-set(port of ai-tool-set v1.0.0, MIT © Chris Cook): declarative activate / deactivate / activateWhen / deactivateWhen for tools with state- and context-aware predicates. Integrates with a newactiveTools?: readonly string[]option oncallModelthat filters which tools are sent to the model for a given call.import { callModel, OpenRouter, serverTool, tool } from "@openrouter/agent"; import { createToolSet } from "@openrouter/agent/tool-set"; import { z } from "zod/v4"; type AppContext = { accountId: string }; // Curried form preserves the literal name for correlated tool event types. const listOrders = tool<AppContext>()({ name: "list_orders", inputSchema: z.object({}), execute: async (_params, ctx) => ({ accountId: ctx?.shared.accountId, orders: [], }), }); // override the default `server:${type}` id const search = serverTool( { type: "web_search_2025_08_26" }, { id: "public_search" } ); const toolSet = createToolSet({ tools: [listOrders, search] as const, }).deactivate("list_orders"); const client = new OpenRouter({ apiKey: process.env["OPENROUTER_API_KEY"] }); const resolved = toolSet.resolve(); // resolved.callModel is `{ tools, activeTools }` — spread it straight in const result = callModel(client, { model: "openai/gpt-4o-mini", input: "Search for OpenRouter pricing.", ...resolved.callModel, });
-
#114
66d7232Thanks @mattapperson! - AddtoolChoicetonextTurnParams, so a tool can change which tools the model may call on the following turn without touching thetoolsarray.This is what a tool-search tool needs: declare every tool up front, keep the not-yet-needed ones out of reach behind an
allowed_toolschoice, and widen that choice as the model discovers what it wants. Becausetoolsis byte-identical across turns, the provider's prompt-cache prefix survives — which is the whole reason to withhold tools rather than send them all.import { callModel, OpenRouter, tool } from "@openrouter/agent"; import { z } from "zod/v4"; const allowed = (names: string[]) => ({ type: "allowed_tools" as const, mode: "auto" as const, tools: names.map((name) => ({ type: "function", name })), }); const toolSearch = tool({ name: "tool_search", inputSchema: z.object({ pattern: z.string() }), execute: ({ pattern }) => findMatchingToolNames(pattern), nextTurnParams: { // Append, never rebuild: dropping a name revokes a tool the model may // already have used, and reordering churns the request for nothing. toolChoice: ({ pattern }, context) => allowed([ ...namesIn(context.toolChoice), ...findMatchingToolNames(pattern), ]), }, }); const client = new OpenRouter({ apiKey: process.env["OPENROUTER_API_KEY"] }); const result = callModel(client, { model: "openai/gpt-4o-mini", input: "What is the weather in Tokyo?", tools: [toolSearch, getWeather, sendEmail, listRepos], // Only the search tool is reachable until it finds something. toolChoice: allowed(["tool_search"]), });
-
#90
e8d7d6dThanks @LukasParke! - Async tool support: a unifiedrun()tool interface with lifecycles, model-side task check-ins, steering, subagent tools (tool.agent()), per-tool cancellation & timeouts, and tool concurrency controls.One tool interface. Every tool is declared the same way: a
runhandler (async function or async generator) pluslifecycle: 'sync' (default) | 'background' | 'deferred'. Generator yields become the task's log (feeding check-ins,tool.preliminary_resultevents, and transcripts); the generator's return is the result, validated againstoutputSchema. Non-generator bodies log viactx.log(). The releasedexecute/execute: false/onToolCalledforms are unchanged.'background'— the loop keeps going. Work settling within the grace window (graceMs, default 250ms) behaves like a sync call; otherwise the model receives a pending placeholder immediately (satisfying the provider requirement that everyfunction_callin follow-up history has a paired output) and the result is injected as atool_task_resultuser message when it settles.asyncTools.onRunEnd: 'drain' (default) | 'detach' | 'cancel'governs run end.'deferred'—runreturnsctx.defer(taskId)to park the call on durable external work; the run pauses with the newConversationStatus'awaiting_async_tools'. The built tool carries typed.resolve()/.fail()/.cancel()completion methods (output checked againstoutputSchemaat compile time and runtime), callable from any process holding theStateAccessor;resumeToolResults()is the low-level batch entry point. Double resolution throwsToolTaskAlreadySettledError.
Model-side task interactions. When any long-running tool is registered, the SDK appends ONE universal
tasktool — a single static wire definition no matter how many async tools exist (per-tool schemas are never augmented; context cost stays constant). The model addresses tasks bytaskId:action: 'check' (default) | 'steer' | 'result' | 'cancel', withview: 'status' | 'logs' | 'transcript'for checks. Calls are engine-intercepted and dispatched to the OWNING tool'scheck: { schema, execute }config when declared (customparamsvalidated againstcheck.schema), else the SDK default views — universal interface, tool-specific handling. Check handlers receiveturnContext.toolCallStatus,turnContext.accumulatedYieldedEvents, and aturnContext.taskhandle (statusView/tailLogs/transcript/send/cancel). Task-tool calls are doom-loop-exempt and bypass concurrency/timeout gates. Opt out withasyncTools: { checkins: false }. After a process restart, deferred tasks answerstatusfrom persisted state (including a boundedlastLog— a new additivePendingAsyncToolfield).Steering. Running tasks have an inbox:
runbodies opt in viactx.onMessage(handler); deliver from code withModelResult.sendToTask(taskId, message)or from the model via a custom check param forwarded withturnContext.task.send(...). NewModelResult.queueUserMessage(text)injects a user message at the next safe turn boundary.Subagent tools.
tool.agent()creates a tool whose work IS a childcallModelconversation, running as a background task: the parent loop keeps going, each child turn becomes a log entry, the child conversation is the check-in transcript (statusaddsturnsCompleted/currentActivity), theresultmapper (default:{ text: await child.getText() }) shapes the delivered output,cancelTask/ parent abort /timeoutMscancel the child, and steering messages are injected into the child as user messages. Children run in-memory and do not inherit parent hooks (pass child hooks in theagentspec explicitly).Cancellation & timeouts. Tool contexts carry
ctx.signal(fires on run abort, per-tooltimeoutMs/ run-leveltoolTimeoutMs,cancelTask,ModelResult.cancel()), plusctx.callId/ctx.conversationId. Timeouts bound the round's wait, not the tool body ({ error, code: 'tool_timeout' }). Behavior change:ModelResult.cancel()now also aborts in-flight tool work (previously stream-only).Concurrency.
toolConcurrency: number | { round?, background? }(round unbounded by default; background pool default 16) plus per-toolmaxConcurrency. Output order stays call order.Events. New
tool.async_started/tool.async_settled(withdelivery: 'injected' | 'pending_resume' | 'dropped'); progress reusestool.preliminary_result;tool.resultfires exactly once per call with the final value.ModelResult.getAsyncTasks()inspects live tasks. Doom-loop detection treats a late-result delivery as forward progress.State fields (
pendingAsyncToolswithlastLog,settledAsyncCallIds) are additive within ConversationState version 1. New subpath exports:resume-tool-results,tool-concurrency,async-tool-registry,tool-task,tool-check,agent-tool. The reserved tool nametaskis rejected bytool()and, when supplied dynamically, suppresses the built-in with a warning.Note:
tool.background()andtool.deferred()existed only on this PR's branch and were never published; they are replaced bylifecycle. No released consumer is affected.import { callModel, tool } from "@openrouter/agent"; import { z } from "zod"; // Background: ordinary run; the loop keeps going while it works. const renderVideo = tool({ name: "render_video", lifecycle: "background", inputSchema: z.object({ script: z.string() }), outputSchema: z.object({ url: z.string() }), ack: "Rendering started.", timeoutMs: 300_000, check: { schema: z.object({ focus: z.string().optional() }), // validates task({ params }) execute: async (params, turnContext) => { if (params.focus) turnContext.task?.send(params.focus); return turnContext.task?.statusView(); }, }, run: async function* ({ script }, ctx) { const job = await renderer.start(script, { signal: ctx?.signal }); ctx?.onMessage((msg) => job.reprioritize(msg)); for await (const p of job.progress()) yield { pct: p }; // → task log + events return job.result(); // → delivered result }, }); // Deferred: durable external work, resumed from any process. const legalReview = tool({ name: "request_legal_review", lifecycle: "deferred", inputSchema: z.object({ contractId: z.string() }), outputSchema: z.object({ approved: z.boolean() }), run: async ({ contractId }, ctx) => ctx!.defer( (await legal.open(contractId, { conversationId: ctx?.conversationId })) .id ), }); // Subagent: a child conversation as a background tool. const researcher = tool.agent({ name: "research_topic", inputSchema: z.object({ topic: z.string() }), outputSchema: z.object({ text: z.string() }), agent: ({ topic }) => ({ model: "openai/gpt-4o", input: `Research: ${topic}`, tools: [searchTool] as const, }), }); const result = callModel(client, { model: "openai/gpt-4o", input: "Render the explainer, get it approved, and research the market", tools: [renderVideo, legalReview, researcher] as const, state, toolConcurrency: { round: 4 }, }); // The model interacts with running tasks through ONE universal tool: // task({ taskId: "task_7f3" }) → status view // task({ taskId, view: "logs", tail: 5 }) → recent progress // task({ taskId, view: "transcript" }) → agent child conversation // task({ taskId, action: "steer", message: "shorter" }) → steers the job // task({ taskId, action: "cancel" }) → stops it // Developer-side observability & control: result.getAsyncTasks(); result.sendToTask(taskId, "prioritize accuracy"); result.cancelTask(taskId); // Webhook handler — different process, days later. Typed by outputSchema. await legalReview.resolve(client, { state: makeAccessor(conversationId), taskId: ticketId, output: { approved: true }, run: { model: "openai/gpt-4o" }, });
-
#73
78c562eThanks @LukasParke! - Doom-loop detection for the tool-execution loop (opt-in viadoomLooponcallModel).Catches runs that stop making progress while continuing to spend: the model re-issuing the same tool call with identical arguments in consecutive rounds (including repeated empty
{}calls and repeated invalid-JSON calls), repeating identical server-tool requests (web_search_calletc., detected post-execution at the step checkpoint), or emitting the same text tokens over and over. Detection is deterministic — a verdict is a pure function of the transcript — and responds through a configurable graduated ladder:observe(emit the newDoomLoopDetectedhook) →steer(inject corrective guidance; queued guidance persists across pauses) →block(refuse the call with an explanatory tool error, before execution) →stop(halt before any further model request; unresolved calls in the final turn get synthesized halt-error outputs so persisted history stays well-formed;SessionEnd.reason: 'doom_loop').Streaks are round-scoped: N identical calls fanned out in parallel within one round count once (a streak measures the model re-issuing a call after seeing its result). Tools declare call identity via
loopKeyon the tool definition — a computed function over the call's validated arguments (e.g.({ command, cwd }) => ({ command, cwd }); returningnullexempts a call), orfalse(statically exempt); absent means the full validated arguments. MCP-wrapped tools acceptloopKeyviamarkMcp(tool, { loopKey }). Fingerprints are a cross-port contract: RFC 8785 (JCS) canonicalization + SHA-256 over UTF-8 via WebCrypto, with conformance vectors intests/vectors/doom-loop-fingerprints.jsonfor the Python/Go ports. Unhashable key material (bigint, circular, >64 deep) falls back to the full-arguments identity — detection never fails a run.Detector state persists inside
ConversationState.doomLoop: streaks survive serialize → resume, astopverdict survives decision-only resumes (approve/reject) and clears on a fresh conversational turn, and queued steer guidance is delivered on resume. Ladder configs warn on dead rungs and onblockwithstop: false(unbounded block/re-issue). Documented, test-locked limits: varying-input (nonce) loops evade the default identity without aloopKey; paraphrased text repetition is not detected; manual/client-executed calls are not recorded. New@openrouter/agent/doom-loopsubpath exports the primitives;ModelResult.getDoomLoopVerdict()reports a stopping verdict. -
#73
78c562eThanks @LukasParke! - Doom-loop escalation recovery: a newescalateladder rung betweensteerandblockthat unblocks a stuck run by throwing more intelligence at the next turn instead of refusing or halting.Configure via
doomLoop.escalation:modelruns the NEXT turn on a stronger model (one-turn override, automatic revert), and/oradvisorforces anopenrouter:advisorconsult (the advisor server tool is appended withforwardTranscript: trueand loop-diagnosing instructions, andtoolChoiceis pinned to it viaallowed_tools/requiredso the stuck model must ask for guidance first; an object form passes through as advisor parameters). A user notice naming the detected loop accompanies the escalated turn.Escalations are real spend on a run already suspected of wasting it, so they are budgeted:
maxEscalations(default 2) caps recoveries per conversation, budget is consumed when a recovery is applied (not at verdict time),escalationsUsedpersists inConversationState.doomLoopso resumes cannot reset it, and concurrent detector verdicts in one window escalate once. Exhausted or unconfigured escalations fall through to the weaker rungs; resolve-time warnings flag anescalaterung without a mechanism (and vice versa). TheDoomLoopDetectedhook'saction/overrideActionenums gain'escalate'— an override without config/budget downgrades toobserve, never silently to a stronger action. -
#89
75271c3Thanks @LukasParke! - Fix doom-loop detection missing a repeated same-tool fan-out.Streaks compared a tool's last fingerprint, so
read(a), read(b), read(c)reissued verbatim had a different last call every round and each round's first call reset the streak to 1. Eight identical rounds of a three-call fan-out produced zero detections, while single-call rounds tripped at round 2 — and distinct-argument fan-out is the dominant shape in parallel-tool-calling agents.A round's identity for one tool is now the set of fingerprints it was called with, compared across rounds. The engine declares a round's complete set before any of its calls is scored, so ordering within the round does not matter, a changed member resets the streak, and neither a strict subset nor a superset is a repeat — a round that adds new work is progress, not repetition. Every call in a repeating round reports that round's streak, so at the block rung a repeating fan-out stops spending rather than only its last call being refused.
Per-call streaks accumulate alongside the round-set streak, and the stronger evidence decides. Each
(tool, arguments)identity counts its own consecutive rounds, whatever its round-mates did — so a call repeating inside varying company ([a,b],[a,c],[a,d]:ais a 3-peat) is flagged even though every round's set differs, a repeat keeps counting when a paused HITL member drops from the resumed round, and undeclared paths (server-tool records, direct callers) get order-independent per-call detection without a declaration. When the per-call count alone crosses a rung, only that call is refused and its verdict quotes its own identity; genuinely new round-mates run free. For an exactly-repeating round both counts are equal, so nothing double-fires. A partial repeat ([a,b,c]then[a,b]) flags the re-issued calls at the observe rung rather than being invisible; a superset round ([a,b],[a,b],[a,b,c]) flags the repeated members while the new call always executes.A call that a round's declaration could not include (unhashable key material) cannot inherit or move the round's counters; its own verbatim repetition still accumulates per-call evidence like any other repeat.
Resumed runs: a multi-call round's fingerprint set and per-call counts are persisted alongside its streak (new optional
roundFingerprintsandcallStreaksonDoomLoopStreak— additive; pre-existing blobs restore with their old single-call semantics). A repeating fan-out therefore keeps its evidence across save/resume boundaries: approval pauses no longer reset a fan-out sitting at the block rung, and per-turn-resume topologies (onecallModelper user turn, state persisted between) accumulate across turns instead of re-baselining on every one. Because the streak travels with the exact set that earned it, a resumed round containing only a subset of that set is a different round and starts at 1 — a lesser call can never inherit a fan-out's evidence. Single-call streaks behave exactly as before.New API:
DoomLoopMonitor.declareRound(round, calls)— declares a round's complete call set before any of it is scored.DoomLoopMonitoris exported, so this is a new public method, additive only. Callers usingcallModelneed not touch it (the engine calls it); directDoomLoopMonitorusers and SDK ports should, so a repeating fan-out is flagged as one unit (shared verdict, shared steer message) rather than only via each member's individual per-call count.Single-call round timing, in-round duplicate collapsing, verdict payloads, and the number of times a tool's
loopKeyis invoked (once per checked call) are unchanged. The persisted shape gains two optional fields (roundFingerprintsandcallStreaks, both above); everything existing is untouched and old blobs restore cleanly with their old semantics.Newly reachable false positive. The detector compares arguments, not results, so repetition shapes that were previously invisible now accumulate and are refused at the default
blockrung from round 3. Two variants:- A stable set of parallel arguments every round — an agent re-reading the same context files each turn, or a fixed fan-out of pollers — blocks with one synthesized error per call in the round.
- A single call re-issued verbatim while its round-mates CHANGE — re-reading an
anchor file (README, config, schema) while exploring new files each turn
(
[a],[a,b],[a,b,c]:ablocks from round 3 even though every round adds work). The per-call detector counts the call's own consecutive rounds, so the round being "progress" does not exempt a member that itself repeats: a file already read is in context, and re-reading it is spend without progress.
Exempt such tools with
loopKey: false(or aloopKeyreturningnullfor the call). These classes were invisible to the detector before, so no existing exemption covered them; the graduated ladder gives every shape a free round and anobservewarning before anything is refused.For
callModelusers, nothing to change —doomLoopis configured exactly as before, and the engine declares each round for you. What changed is when it fires:import { callModel } from "@openrouter/agent"; const result = callModel(client, { model: "z-ai/glm-5.2", input: "Summarize these files.", tools: [readTool], // Unchanged config; the ladder default is observe@2, block@3, stop@6. doomLoop: true, }); // Say the model reissues the SAME three-call fan-out every round: // round 1: read(a), read(b), read(c) // round 2: read(a), read(b), read(c) <- identical set // // was: no detection, ever. Each round's first call reset the streak, so // a fan-out could spin indefinitely while single calls tripped at // round 2. // now: round 2 is streak 2 (observe), round 3 is streak 3 (block) — and // EVERY call of the round is refused at the block rung, not just one, // so the fan-out stops spending. // // A round that ADDS work resets the ROUND streak, but each repeated call // keeps its own count — the model re-read a, b, c a third time: // round 3: read(a), read(b), read(c), read(d) // -> a, b, c blocked (3rd consecutive round each); d executes. // // `loopKey` still runs exactly once per checked call. Persisted state gains // two optional fields so fan-out and per-call evidence survive save/resume; // old state restores cleanly.
Driving
DoomLoopMonitordirectly (or porting it) is the case that needs the new call — declare a round's whole batch before recording any of it.resolveDoomLoopOptionandResolvedDoomLoopConfigare now exported too:DoomLoopMonitorwas previously exported without its config resolver, so it could not actually be constructed from the public API.import { DoomLoopMonitor, resolveDoomLoopOption } from "@openrouter/agent"; const monitor = new DoomLoopMonitor(resolveDoomLoopOption(true)); for (const [round, batch] of batches.entries()) { // NEW: declare the round's complete set BEFORE recording any of its calls, // so a repeating fan-out is scored as one unit. (Per-call repetition is // detected either way; the declaration adds whole-round identity.) await monitor.declareRound( round, batch.map((call) => ({ toolName: call.name, keyMaterial: call.arguments, })) ); for (const call of batch) { const { verdict } = await monitor.recordToolCall( call.name, call.arguments, round ); if (verdict?.action === "block") refuse(call, verdict.message); } }
-
#97
a629cf1Thanks @LukasParke! - NewModelResult.getUsage()accessor: aggregate token/cost usage across every model call a run made.const result = callModel(client, { model, input, tools }); for await (const item of result.getItemsStream()) { render(item); } // new: aggregate totals across EVERY round of the tool loop const usage = await result.getUsage(); console.log(usage.modelCalls, usage.totalTokens, usage.cost); // was (and still is): the FINAL round's response only const response = await result.getResponse(); console.log(response.usage?.totalTokens);
getResponse()resolves to the final round's response, so in a multi-round tool loop the tokens spent on the intermediatetool_callsgenerations were unreachable — andgetItemsStream()carries output items only, never surfacing theresponse.completedevents that hold each round's usage block. Callers streaming items therefore had no way to account for a run's real token spend without registering a hook.await result.getUsage()returns the sameSessionUsageTotalsshape as theSessionEndhook'stotalUsage(modelCalls,inputTokens,outputTokens,totalTokens,cachedTokens,reasoningTokens, andcostwhen the server reported it), summed over the initial request, each tool-round follow-up, the empty-final retry, theallowFinalResponsefinal turn, and approval-resume requests. It gates on run completion likegetResponse()does, so totals are final whether awaited directly, aftergetResponse(), or after draining any streaming getter — includinggetItemsStream()(on an approval-resumed run, reading usage never advances the tool loop; awaitgetResponse()/getText()first for final totals there). UnlikegetResponse()it never rejects (a failed run still consumed tokens), returning the totals accrued so far.The usage aggregate is now accumulated independently of the hook system, so it is correct for callers who configured no hooks at all; previously it only advanced as a side effect of
PostModelCallemission.SessionEnd.totalUsageandgetUsage()read from one snapshot helper and cannot drift. -
#73
78c562eThanks @LukasParke! - Run-level cancellation and per-request timeout composition.New
signaloption oncallModel: aborting it stops the tool-execution loop at the next turn boundary AND aborts the in-flight API request/stream, so a stalled provider fails fast with the abort reason instead of hanging until an outer caller/test timeout. A pre-aborted signal fails before any network dispatch.RequestOptions.timeoutMs(the thirdcallModelargument) now reliably bounds each request the loop makes even when a signal is present: the underlying SDK skips its owntimeoutMswiring whenever a request carries a signal, so the engine composes{run signal, caller signal, per-request timeout}viaAbortSignal.anyper dispatch — each request gets a fresh timeout budget (not one shared per-run timer), and whichever bound fires first wins. -
#99
3028554Thanks @devin-ai-integration! - Addstrictto all client function-tool definitions, includingtool.agent(), and pass it through serialization instead of hardcodingstrict: null, so providers can enforce structured-outputs-style schema adherence on tool-call arguments.The SDK forwards the caller's generated schema unchanged and propagates provider validation errors. OpenAI-style strict schemas require every object property to be listed in
required; use Zod.nullable()for conceptually optional values because.optional()allows the key to be omitted.import { tool } from "@openrouter/agent"; import { z } from "zod/v4"; const searchTool = tool({ name: "search", inputSchema: z.object({ query: z.string() }), strict: true, // was: silently dropped (serialized as strict: null) // now: serialized as strict: true on the wire tool definition execute: async ({ query }) => runSearch(query), });
-
#95
5a7ed03Thanks @LukasParke! - Clarify thevalidateFinalResponseerror messages so an empty final turn can't be misread as "validation rejected my tool call" (issue #45).Invalid final response: empty or invalid outputnow names the actual defect:output array is empty (length 0) for response "<id>"— with the response id and a pointer to thestrictFinalResponse/allowFinalResponseoptions — versusoutput is not an array (got <type>)when the payload is malformed.Invalid final response: missing required fieldsnow lists which fields were absent (id,output, or both).Diagnostics only — no behavior change. Validation remains a pure array-length check, so tool-call-only output still passes (it always did; that was the misdiagnosis in #45). Both historical message prefixes are unchanged, so any matcher on them keeps working.
-
#91
231fb65Thanks @w0nche0l! - Thread the executed tool call into the hook execute context.context.toolCallis part of the tool-facing contract, but only the non-streaming orchestrator populated it — the streamingModelResultloop builds its turn context with justnumberOfTurns, soexecute/onToolCalledhooks sawtoolCall: undefinedon the streaming path.buildExecuteCtxnow fills the gap from the executed call: a caller-providedturnContext.toolCallstill wins (the orchestrator's carriesstatus), and otherwise the executedParsedToolCallis converted back to a wire-shapedFunctionCallItem. TheonResponseReceivedpath intentionally threads nothing — only thefunction_call_outputitem is in scope there. -
#100
0efdbb0Thanks @devin-ai-integration! - Relax an unchanged forcedtoolChoice(required, a specific tool, orallowed_toolswithmode: 'required') toautoafter it produces a tool call, including follow-ups resumed after approval, HITL, client-tool, or async-tool pauses. Dynamically resolved choices re-arm when their semantic value changes. This lets the model synthesize a final text answer instead of being forced to call tools until the step budget runs out (DEV-785).const result = callModel(client, { model: "openai/gpt-4o", input: "Plan, research, then submit.", tools: [planTool, searchTool, submitTool] as const, toolChoice: ({ numberOfTurns }) => numberOfTurns === 0 ? { type: "function", name: "plan" } : numberOfTurns === 3 ? { type: "function", name: "submit" } : "auto", });
-
#66
c83ccebThanks @LukasParke! - Add a versionedConversationStateserialization contract.- Optional
versionfield onConversationState(absence means v1);createInitialStatenow stampsversion: 1. - New helpers:
serializeConversationState/deserializeConversationState(package root +@openrouter/agent/conversation-state). - Typed errors:
UnsupportedStateVersionError({found, supported}) andInvalidStateErrorfor malformed payloads. - Compat policy: treat JSON as opaque; additive changes within a major; migrations run in
deserializeConversationStateon version bump. StateAccessor load/save is unchanged — helpers are opt-in wrappers over what consumers already do withJSON.stringify/parse.
- Optional
-
#68
6807c51Thanks @LukasParke! - The forced final turn afterstopWhenhalts mid-tool-call is now on by default and usestoolChoice: 'none'instead of strippingtools(stripping busted the prompt-cache prefix). It appends a built-in final-answer directive (exported asDEFAULT_FINAL_RESPONSE_DIRECTIVE) as the final user message. Previously the final turn required opting in viaallowFinalResponse, stripped the tools block, and baretrueappended no directive — models that emit tool-call syntax as text (e.g. GLM) would attempt another tool call and leak unparsed<tool_call>…text into the final content (DEV-658).callModel(client, { model: "z-ai/glm-5.2", input: "Research this step by step.", tools: [searchTool], stopWhen: stepCountIs(3), // was: no final turn unless allowFinalResponse was set; bare `true` // stripped tools (cache-busting) and appended no directive, so // GLM-style models could leak raw `<tool_call>…` as the answer // now: default-on final turn with toolChoice:'none' (tools kept, cache // preserved) + DEFAULT_FINAL_RESPONSE_DIRECTIVE user message // custom wording still overrides the default: // allowFinalResponse: 'Summarize what you found.', // append no message (turn still happens): // allowFinalResponse: '', // restore the old opt-out (no final turn, run ends on the tool-call turn): // allowFinalResponse: false, });
Note: runs that previously ended on a halted tool-call turn now make one additional model request by default. Pass
allowFinalResponse: falseto keep the old behavior. -
#64
e4d06e3Thanks @LukasParke! - Persist unresolved manual tool calls (execute: false/ no execute fn) toConversationState.pendingToolCallswhen the loop stops, and set status to the new value'awaiting_client_tools'.Previously, HITL pauses (
onToolCalled → null) correctly populatedpendingToolCallswith status'awaiting_hitl', but bare manual tools onlybreak'd the loop —getPendingToolCalls()returned[]and status was leftin_progress/complete. Cold-start consumers could not recover the unresolved calls from serialized state.- New
ConversationStatusvalue:'awaiting_client_tools'(additive; does not replace'awaiting_hitl'). - Mixed auto+manual rounds still execute/persist regular tool outputs, then pause with only the unresolved manual calls in
pendingToolCalls. - A successful resume with new input from
'awaiting_client_tools'clears the stale pendings and continues as a normal turn. Failed resume requests leave the paused state intact. Manual tools are not approved/rejected via call IDs (unlike HITL/awaiting_approval).
- New
-
#7
80ff8a7Thanks @mattapperson! - Add a typed lifecycle hook system tocallModel, inspired by the Claude Agent SDK hooks pattern.Two usage modes: an inline config object (built-in hooks only) or a
HooksManagerinstance (custom hooks, dynamic registration viaon()/off()/removeAll(), programmaticemit()).Eight built-in hooks:
PreToolUse(block or mutate tool input before every client-tool execution),PostToolUse/PostToolUseFailure(observe results and errors with timing),UserPromptSubmit(mutate or reject the prompt before the initial request),PermissionRequest(programmatically allow/deny/ask for tools requiring approval),Stop(force-resume a halted loop or inject a follow-up prompt, capped against runaway handlers), andSessionStart/SessionEnd(paired once per run on every exit path, including approval pauses, interruptions, errors, and no-tools streaming paths).Features: tool matchers (string / RegExp / predicate), payload filter predicates, sequential mutation piping, short-circuit on block/reject, async fire-and-forget handlers with
drain()/abortInflight()/ per-handler timeouts and cooperative cancellation viactx.signal, configurable error handling (throwOnHandlerError), and custom hook definitions via Zod schema pairs with full TypeScript inference (transforms/defaults are honored — handlers receive parsed output values).The API is additive: existing
onTurnStart,onTurnEnd, andrequireApprovalare unchanged. Public exports:HooksManager,HookName,isAsyncOutput, and the payload/result/config types; also available via the@openrouter/agent/hooks-managersubpath. -
#56
209499aThanks @mattapperson! - Add asourcediscriminant to tool results so untyped MCP tools no longer collapse the type safety of typed tools.Previously, mixing an MCP tool (whose output schema is
unknown) with fully-typed tools in onecallModel({ tools })array collapsed the entire result union tounknown— one untyped tool poisoned every other tool's result type.ToolExecutionResult(andToolExecutionResultUnion) now carrysource: 'client' | 'mcp'. Narrowing onsource === 'client'recovers the precise, schema-derived results for your own tools; MCP results stay isolated asunknownundersource === 'mcp'.ToolResultEvent(streaming:getFullResponsesStream,getToolStream) gains the samesourcefield. Breaking: thetool.resultevent payload now includessource; consumers that constructed or exhaustively matched these events may need to account for it.@openrouter/agentexports amarkMcp()helper, anisMcpTool()guard, and theMcpBrandedtype.@openrouter/mcpbrands every wrapped tool (including syntheticlist_resources/read_resource) so the discrimination is automatic — callers just spreadmcp.toolsas before.- MCP tools continue to execute locally and serialize to the wire as
type: 'function'; the brand is purely informational and does not change runtime behavior.
-
#67
cb83f45Thanks @LukasParke! - Add aPostModelCalllifecycle hook and aggregate usage totals onSessionEnd— the telemetry primitives for tracing and benchmark consumers.PostModelCallfires once per completed model response, on every request the agent loop makes: the initial request, each tool-round follow-up, the empty-final retry, theallowFinalResponsefinal turn, and approval-resume requests. The payload carriesresponseId(the OpenRouter generation id, deep-linkable),model,durationMs(dispatch to fully materialized response, including stream consumption),turnType('initial' | 'resume' | 'tool_round' | 'final' | 'retry'),turnNumber, and a normalizedusageblock (inputTokens,outputTokens,totalTokens,cachedTokens,reasoningTokens,cost?) when the server reported usage accounting. Purely observational: handlers cannot mutate or block.SessionEndnow carries an optionaltotalUsageaggregate (modelCallsplus the summed usage fields, withcostpresent when any call reported one) whenever at least one model call completed during the run.New exported types:
PostModelCallPayload,ModelCallUsage,SessionUsageTotals.
-
#62
1362232Thanks @LukasParke! - Docs: fix three README/API drifts found while building production agents — tool context isctx.local(notctx.context); thestateoption takes aStateAccessor({load, save}) and state is read viaresult.getState()(not(await getResponse()).state);getToolStream()emits argument deltas + generator preliminary results, while execution results are ongetFullResponsesStream(). Adds a streams cheat-sheet table. -
#65
09a041eThanks @LukasParke! - Infer tool context types fromcontextSchemaend-to-end:tool()now preserves the concrete Zod schema through its overloads, soexecute'sctx.localis typed from the schema andcallModel'scontextmap slots accept/reject the real per-tool shape — no morectx.local as Xorcontext: map as any. Tools without acontextSchemastill resolve their map slot toRecord<string, never>. Types-only; runtime behavior unchanged. -
#61
c020bc7Thanks @LukasParke! - Fix: bare stringinputis now normalized into a message item when resuming a conversation with loaded history. Previously the raw string was appended to the request input array un-normalized, causing an OpenResponses 400 validation error on the advertised string-input style. -
#63
d96cd9fThanks @LukasParke! - Tolerate empty finaloutputafter completed tool rounds: retry the follow-up request once, then resolve successfully with empty text instead of throwingInvalid final response: empty or invalid output. Mini-class models intermittently treat a successful tool call as the terminal answer. Opt into the old throw withstrictFinalResponse: true. Runs with no completed tool work still throw on empty output. -
#59
8edae63Thanks @ayush-or! - Stop the tool-execution loop when a round contains unresolved manual (client-executed) tool calls, instead of sending a follow-up request whose input carries afunction_callwith no matchingfunction_call_output— a history providers reject with a 400 "No tool output found for function call ...". The response is surfaced so the caller can execute the manual calls and continue, mirroring the existing all-manual behavior.
- #53
a5341f2Thanks @Cybourgeoisie! - Bump @openrouter/sdk to 0.13.7
- Add
allowFinalResponseoption tocallModel, sibling ofstopWhen. When the agent loop is halted bystopWhenwhile the last model response still contains tool calls, the pending tool calls are executed (so they have matching outputs) and one more model request is made with no tools so the loop ends with a natural-language summary instead of an unfinished tool call. Passing a string instead oftrueadditionally appends that string as a finalusermessage (e.g.allowFinalResponse: 'Please summarize what you found.'). The full accumulated input array and the originalinstructionsare sent.
- #42
8e71f06Thanks @mattapperson! - Remove implicit 5-step cap incallModel. WhenstopWhenis omitted, the tool-execution loop now runs until the model produces a turn with no tool calls instead of stopping at 5 steps. Pass an explicitstopWhen(e.g.stepCountIs(n),maxCost(...),maxTokensUsed(...)) to bound iterations.
-
Add human-in-the-loop (HITL) tool type, a new
ClientToolvariant that sits between regularexecutetools andmanualtools. HITL tools define two async hooks:onToolCalled(input, context)runs when the model invokes the tool. Return a value to feed the model directly (like a regularexecutetool), or returnnullto pause the conversation so the caller can supply the output later — the same flow used by manual tools.onResponseReceived(rawResult, context)runs on the next turn when an incomingfunction_call_outputmatches a prior call of this tool. It lets the caller transform or validate the raw response before it reaches the model. Throwing surfaces as a tool error to the model.
HITL tools require an
outputSchema, which is used to validate both theonToolCalledreturn value (when non-null) and caller-supplied responses (after anyonResponseReceivedtransform, or as-is when no hook is defined).New
ConversationStatusvalue'awaiting_hitl'is emitted when one or more HITL tools returnnullfromonToolCalled, signaling that the caller should resume with outputs for the paused calls.New public exports:
- Types:
HITLTool,HITLToolFunction - Guards:
isHITLTool,isAutoResolvableTool(true for execute / generator / HITL tools — i.e. anything that can resolve within a turn)
isManualToolnow returnsfalsefor HITL tools, so existing manual-tool branches continue to behave correctly.
- #34
61aca10Thanks @w0nche0l! - Detect streamed Responses API results by readable stream behavior instead of constructor names or unsupported adapters.
-
#30
e4e3ed5Thanks @mattapperson! - AddserverTool()factory for OpenRouter's server-executed tools (web search,openrouter:datetime, image generation, MCP, file search, code interpreter, and future SDK additions). Server tools can be mixed with clienttool()s in thecallModel({ tools })array; OpenRouter runs them and their output items flow through the unifiedModelResult.allToolExecutionRounds[].toolResultslist.getItemsStream()yields server-tool output items (e.g.web_search_call,openrouter:datetime) alongside clientfunction_call/function_call_outputitems. The yielded union is narrowed from theTToolspassed tocallModel, so consumers only see item types that are reachable for their tool set.StepResult.serverToolResultsexposes provider-side tool invocations tostopWhenconditions (the existingtoolResultsfield remains client-tool-only).- New public exports:
serverTool,isServerTool,isClientTool, and the typesServerTool,ServerToolConfig,ServerToolType,ServerToolResultItem,ClientTool,ToolResultItem.
- #25
ec94de8Thanks @jakobcastro! - Bump @openrouter/sdk from 0.11.2 to 0.12.12, which addsxhighandmaxto theVerbosityenum forTextExtendedConfig
-
#27
ef15761Thanks @mattapperson! - Fixhooksconstructor option silently no-oping when a plain hook object (e.g.{ beforeRequest: ... }) was passed: the underlying SDK only honorshookswhen it is anSDKHooksinstance, and the previous wrapper forwarded the plain object unchanged.new OpenRouter({ hooks })now accepts any of:- an
SDKHooksinstance (used as-is), - a single hook object (
BeforeRequestHook,AfterSuccessHook, etc.), or - an array of hook objects.
Shorthand inputs are normalized into an
SDKHooksinstance before handoff. Hook types (BeforeRequestHook,BeforeRequestContext,AfterSuccessHook,SDKHooks, etc.) are now re-exported from the package entry point. - an
-
#22
ab5a75cThanks @mattapperson! - Fix type exports and add pre-push hooks- Add
NewDeveloperMessageItemtype export for manually added developer messages - Fix
FieldOrAsyncFunctiontype import path in async-params module - Add
.npmignoreto exclude development files from published package - Add husky pre-push hooks for lint and typecheck validation
- Add
- #19
2b23076Thanks @mattapperson! - Re-export SDK model types and add clean item type aliases so consumers don't need to depend on@openrouter/sdkdirectly.
- #20
f0d2d72Thanks @mattapperson! - Re-exportEasyInputMessageContentInputImage,OutputInputImage, andOpenAIResponsesToolChoiceUnionfrom@openrouter/sdk/modelsso consumers can use these types without a direct SDK dependency.
- Re-export SDK model types (
ResponsesRequest,OutputMessage,FunctionCallItem, etc.) from@openrouter/sdk/modelsso consumers don't need a direct dependency on@openrouter/sdk. - Add clean item type aliases (
Item,UserMessageItem,AssistantMessageItem,FunctionResultItem, etc.) via new@openrouter/agentexports. - Add
OpenRouterwrapper class that extendsOpenRouterCorefor a simplified API (@openrouter/agent/openrouter).
- Replace ESLint with Biome for linting and formatting.
- Add CI auto-release workflow on push to main.
- Correct item type aliases to match SDK runtime types.
- #13
93a88a8Thanks @mattapperson! - fix: export OpenRouter class from package entry point
- #4
546b07dThanks @robert-j-y! - Fix type errors in test mocks, add null→undefined sanitization in applyNextTurnParamsToRequest, and release-gate publishing via workflow_dispatch