refactor(ai): reuse shared OpenRouter Responses implementation

This commit is contained in:
Aiden Cline
2026-09-30 11:31:01 -05:00
parent 25d302d02c
commit 95ca8a2aaf
13 changed files with 215 additions and 118 deletions
+4 -4
View File
@@ -1210,10 +1210,10 @@ LLM.request({
Normalized cache usage is read back into `response.usage.cacheReadInputTokens` and `cacheWriteInputTokens` across every provider.
OpenRouter selects its wire API by model family: `openai/*`, `x-ai/*`, and `meta/*` use Responses; `anthropic/*` uses
Anthropic Messages; other families use Chat Completions. All three Responses families share the provider-neutral
Responses serializer and stream parser; there are no per-vendor OpenRouter protocols. Leading-`~` model aliases follow
the same routing. Explicit `openrouter.chat(id)`, `.responses(id)`, and `.messages(id)` selectors are available for
API-specific integrations.
Anthropic Messages; other families use Chat Completions. All three Responses families share the existing OpenAI-shaped
Responses implementation, including namespace tools and hosted-tool events; there are no per-vendor OpenRouter
protocols. Leading-`~` model aliases follow the same routing. Explicit `openrouter.chat(id)`, `.responses(id)`, and
`.messages(id)` selectors are available for API-specific integrations.
Qwen retains Chat's explicit system/conversation cache markers, while Claude uses native Messages cache blocks.
OpenRouter Responses is stateless (`store: false`); every request includes full history, not `previous_response_id`.
Meta currently accepts only automatic tool selection on both its Chat and Responses upstreams; forced/disabled tool
@@ -124,7 +124,7 @@ const OpenAIResponsesCoreFields = {
),
}
const OpenAIResponsesBody = Schema.Struct({
export const OpenAIResponsesBody = Schema.Struct({
...OpenAIResponsesCoreFields,
stream: Schema.Literal(true),
})
+49 -40
View File
@@ -2,9 +2,7 @@ export * as OpenRouterWire from "./openrouter.js"
import { Option, Schema } from "effect"
import { LLMRequest, Message, type ContentPart, type ReasoningPart } from "../../schema/index.js"
import { resolveEffortUpdates } from "../../effort-updates.js"
import type { OpenResponses } from "../open-responses.js"
import { OpenResponsesOptions } from "./open-responses-options.js"
import type { OpenAIResponses } from "../openai-responses.js"
import { isRecord, ProviderShared } from "../shared.js"
const ReplayDetail = Schema.Struct({
@@ -18,11 +16,13 @@ const decodeReplayDetail = Schema.decodeUnknownOption(ReplayDetail)
// OpenRouter's generic Responses API supports chronological effort updates only on eligible models.
export function supportsEffortUpdates(request: LLMRequest) {
if (request.providerOptions?.contextManagement !== undefined) return false
if (request.providerOptions?.truncation === "auto" || request.http?.body?.truncation === "auto") return false
if (Schema.is(Schema.Struct({ mode: Schema.Literal("pro") }))(request.providerOptions?.reasoning)) return false
if (Schema.is(Schema.Struct({ mode: Schema.Literal("pro") }))(request.http?.body?.reasoning)) return false
return (
request.model.compatibility?.supportsEffortUpdates ?? /^~?openai\/gpt-6-(?:astra|sol|luna)$/i.test(request.model.id)
request.model.compatibility?.supportsEffortUpdates ??
/^~?openai\/(?:gpt-6-(?:astra|sol|luna)|gpt-6\.1-sol)$/i.test(request.model.id)
)
}
@@ -32,17 +32,13 @@ export function nativeRequest(request: LLMRequest, format: "responses" | "messag
? fitReasoning(options.reasoning, request.generation?.maxTokens)
: undefined
const disabled = reasoning?.enabled === false || reasoning?.effort === "none"
const updates = resolveEffortUpdates(
request,
OpenResponsesOptions.resolve(request).reasoningEffort ??
(typeof reasoning?.effort === "string" ? reasoning.effort : undefined),
)
return LLMRequest.update(request, {
providerOptions:
format === "responses"
? {
...options,
reasoningEffort: updates.effort,
reasoningEffort:
options.reasoningEffort ?? (typeof reasoning?.effort === "string" ? reasoning.effort : undefined),
}
: {
...options,
@@ -58,7 +54,7 @@ export function nativeRequest(request: LLMRequest, format: "responses" | "messag
return { type: "adaptive", ...(reasoning.exclude === true ? { display: "omitted" } : {}) }
})(),
},
messages: (format === "responses" ? updates.request : request).messages.map((message) => {
messages: request.messages.map((message) => {
if (
!message.content.some(
(part) => part.type === "reasoning" && part.providerMetadata?.openrouter?.reasoningDetails !== undefined,
@@ -94,29 +90,21 @@ export function nativeRequest(request: LLMRequest, format: "responses" | "messag
]
}
const blocks = replay.flatMap<ReasoningPart>((detail) => {
if (detail.type === "reasoning.text" && detail.signature)
return [
{
...part,
text: detail.text ?? part.text,
providerMetadata: {
...part.providerMetadata,
openrouter: { ...part.providerMetadata?.openrouter, signature: detail.signature },
},
const metadata = (() => {
if (detail.type === "reasoning.text" && detail.signature) return { signature: detail.signature }
if (detail.type === "reasoning.encrypted" && detail.data) return { redactedData: detail.data }
})()
if (!metadata) return []
return [
{
...part,
text: metadata.signature ? (detail.text ?? part.text) : "",
providerMetadata: {
...part.providerMetadata,
openrouter: { ...part.providerMetadata?.openrouter, ...metadata },
},
]
if (detail.type === "reasoning.encrypted" && detail.data)
return [
{
...part,
text: "",
providerMetadata: {
...part.providerMetadata,
openrouter: { ...part.providerMetadata?.openrouter, redactedData: detail.data },
},
},
]
return []
},
]
})
return blocks.length > 0 ? blocks : [part]
}),
@@ -125,24 +113,44 @@ export function nativeRequest(request: LLMRequest, format: "responses" | "messag
})
}
export function responsesOptions<Body extends Pick<OpenResponses.OpenResponsesBody, "reasoning">>(
request: LLMRequest,
body: Body,
) {
export function responsesOptions(request: LLMRequest, body: OpenAIResponses.OpenAIResponsesBody) {
const { usage: _, ...options } = bodyOptions(request.providerOptions, request.generation?.maxTokens)
return {
...options,
...body,
store: false as const,
// Native lowering may freeze effort at the history baseline; keep it instead of the current effort.
...(options.reasoning || body.reasoning ? { reasoning: { ...options.reasoning, ...body.reasoning } } : {}),
...(options.reasoning || body.reasoning
? {
reasoning: {
...options.reasoning,
...body.reasoning,
effort: body.reasoning?.effort,
summary:
body.reasoning?.summary ??
(typeof options.reasoning?.summary === "string" ? options.reasoning.summary : undefined),
},
}
: {}),
...(options.text || body.text ? { text: { ...options.text, ...body.text } } : {}),
}
}
export const bodyOptions = (input: unknown, maxTokens: number | undefined) => {
const openrouter = isRecord(input) ? input : {}
const { usage, models, provider, plugins, web_search_options, debug, user, reasoning, promptCacheKey, ...options } =
openrouter
const {
usage,
models,
provider,
plugins,
web_search_options,
debug,
user,
reasoning,
text,
promptCacheKey,
...options
} = openrouter
return {
...options,
...(usage === undefined || usage === true
@@ -159,6 +167,7 @@ export const bodyOptions = (input: unknown, maxTokens: number | undefined) => {
...(isRecord(debug) ? { debug } : {}),
...(typeof user === "string" ? { user } : {}),
...(isRecord(reasoning) ? { reasoning: fitReasoning(reasoning, maxTokens) } : {}),
...(isRecord(text) ? { text } : {}),
}
}
+12 -28
View File
@@ -7,10 +7,10 @@ import { HttpOptions, ProviderID, type CacheHint, type ModelID, type OpenString
import type { ProviderPackage } from "../provider-package.js"
import { SystemOne } from "../experimental/system-one.js"
import { OpenAIChat } from "../protocols/openai-chat.js"
import { OpenResponses } from "../protocols/open-responses.js"
import { OpenAIResponses } from "../protocols/openai-responses.js"
import { AnthropicMessages } from "../protocols/anthropic-messages.js"
import { Framing } from "../route/framing.js"
import { ProviderShared } from "../protocols/shared.js"
import { JsonObject } from "../protocols/shared.js"
import { newBreakpoints, ttlBucket } from "../protocols/utils/cache.js"
import { OpenRouterWire } from "../protocols/utils/openrouter.js"
@@ -58,6 +58,7 @@ export interface OpenRouterOptions {
readonly plugins?: ReadonlyArray<OpenRouterPlugin>
readonly provider?: OpenRouterProviderRouting
readonly reasoning?: Readonly<{
[key: string]: unknown
enabled?: boolean
exclude?: boolean
effort?: OpenString<"none" | "minimal" | "low" | "medium" | "high" | "xhigh" | "max">
@@ -99,29 +100,16 @@ const OpenRouterBody = Schema.StructWithRest(Schema.Struct(OpenAIChat.bodyFields
])
export type OpenRouterBody = Schema.Schema.Type<typeof OpenRouterBody>
const ResponsesReasoning = Schema.StructWithRest(
Schema.Struct({
enabled: Schema.optional(Schema.Boolean),
exclude: Schema.optional(Schema.Boolean),
effort: Schema.optional(Schema.String),
max_tokens: Schema.optional(Schema.Number),
summary: Schema.optional(Schema.String),
}),
[Schema.Record(Schema.String, Schema.Unknown)],
)
const OpenRouterResponsesBody = Schema.StructWithRest(
Schema.Struct({
...OpenResponses.coreFields,
input: Schema.Array(Schema.Union([OpenResponses.InputItem, OpenResponses.ConfigurationUpdate])),
stream: Schema.Literal(true),
...OpenAIResponses.OpenAIResponsesBody.fields,
store: Schema.Literal(false),
reasoning: Schema.optional(ResponsesReasoning),
reasoning: Schema.optional(JsonObject),
text: Schema.optional(JsonObject),
}),
[Schema.Record(Schema.String, Schema.Unknown)],
[JsonObject],
)
const OpenRouterMessagesBody = Schema.StructWithRest(AnthropicMessages.AnthropicMessagesBody, [
Schema.Record(Schema.String, Schema.Unknown),
])
const OpenRouterMessagesBody = Schema.StructWithRest(AnthropicMessages.AnthropicMessagesBody, [JsonObject])
type OpenRouterMessagesBody = Schema.Schema.Type<typeof OpenRouterMessagesBody>
export const protocol = Protocol.make({
@@ -161,19 +149,15 @@ export const protocol = Protocol.make({
})
const responsesProtocol = Protocol.make({
...OpenResponses.protocol,
...OpenAIResponses.protocol,
id: "openrouter-responses",
supportsEffortUpdates: OpenRouterWire.supportsEffortUpdates,
body: {
schema: OpenRouterResponsesBody,
from: (request) =>
OpenResponses.fromRequestWithAdapter(OpenRouterWire.nativeRequest(request, "responses"), {
id: "openrouter-responses",
name: "OpenRouter Responses",
}).pipe(
Effect.map((body) => OpenRouterWire.responsesOptions(request, body)),
Effect.flatMap(ProviderShared.validateWith(Schema.decodeUnknownEffect(OpenRouterResponsesBody))),
),
OpenAIResponses.protocol.body
.from(OpenRouterWire.nativeRequest(request, "responses"))
.pipe(Effect.map((body) => OpenRouterWire.responsesOptions(request, body))),
},
})
const messagesProtocol = Protocol.make({
@@ -12,7 +12,7 @@
"golden"
],
"name": "openrouter-responses/openrouter-gpt-4o-mini-text",
"recordedAt": "2026-09-30T04:48:47.207Z"
"recordedAt": "2026-09-30T16:23:26.190Z"
},
"interactions": [
{
@@ -30,7 +30,7 @@
"headers": {
"content-type": "text/event-stream"
},
"body": "data: {\"type\":\"response.created\",\"response\":{\"id\":\"gen-1790743725-CVE8eRBKqzoJqJHXU1ur\",\"object\":\"response\",\"created_at\":1790743725,\"model\":\"openai/gpt-4o-mini\",\"status\":\"in_progress\",\"completed_at\":null,\"output\":[],\"error\":null,\"incomplete_details\":null,\"tools\":[],\"tool_choice\":\"auto\",\"parallel_tool_calls\":true,\"max_output_tokens\":40,\"temperature\":0,\"top_p\":1,\"presence_penalty\":0,\"frequency_penalty\":0,\"top_logprobs\":0,\"max_tool_calls\":null,\"metadata\":{},\"background\":false,\"previous_response_id\":null,\"service_tier\":\"auto\",\"truncation\":\"disabled\",\"store\":false,\"instructions\":\"You are concise.\",\"text\":{\"format\":{\"type\":\"text\"}},\"reasoning\":null,\"safety_identifier\":null,\"prompt_cache_key\":null,\"prompt_cache_options\":null,\"usage\":null},\"sequence_number\":0}\n\ndata: {\"type\":\"response.in_progress\",\"response\":{\"id\":\"gen-1790743725-CVE8eRBKqzoJqJHXU1ur\",\"object\":\"response\",\"created_at\":1790743725,\"model\":\"openai/gpt-4o-mini\",\"status\":\"in_progress\",\"completed_at\":null,\"output\":[],\"error\":null,\"incomplete_details\":null,\"tools\":[],\"tool_choice\":\"auto\",\"parallel_tool_calls\":true,\"max_output_tokens\":40,\"temperature\":0,\"top_p\":1,\"presence_penalty\":0,\"frequency_penalty\":0,\"top_logprobs\":0,\"max_tool_calls\":null,\"metadata\":{},\"background\":false,\"previous_response_id\":null,\"service_tier\":\"auto\",\"truncation\":\"disabled\",\"store\":false,\"instructions\":\"You are concise.\",\"text\":{\"format\":{\"type\":\"text\"}},\"reasoning\":null,\"safety_identifier\":null,\"prompt_cache_key\":null,\"prompt_cache_options\":null,\"usage\":null},\"sequence_number\":1}\n\ndata: {\"type\":\"response.output_item.added\",\"output_index\":0,\"item\":{\"id\":\"msg_tmp_ilrlnvtd9l9\",\"type\":\"message\",\"status\":\"in_progress\",\"role\":\"assistant\",\"content\":[]},\"sequence_number\":2}\n\ndata: {\"type\":\"response.content_part.added\",\"output_index\":0,\"item_id\":\"msg_tmp_ilrlnvtd9l9\",\"content_index\":0,\"part\":{\"type\":\"output_text\",\"text\":\"\",\"annotations\":[],\"logprobs\":[]},\"sequence_number\":3}\n\ndata: {\"type\":\"response.output_text.delta\",\"output_index\":0,\"item_id\":\"msg_tmp_ilrlnvtd9l9\",\"content_index\":0,\"delta\":\"Hello\",\"logprobs\":[],\"sequence_number\":4}\n\ndata: {\"type\":\"response.output_text.delta\",\"output_index\":0,\"item_id\":\"msg_tmp_ilrlnvtd9l9\",\"content_index\":0,\"delta\":\"!\",\"logprobs\":[],\"sequence_number\":5}\n\ndata: {\"type\":\"response.output_text.done\",\"output_index\":0,\"item_id\":\"msg_tmp_ilrlnvtd9l9\",\"content_index\":0,\"text\":\"Hello!\",\"logprobs\":[],\"sequence_number\":6}\n\ndata: {\"type\":\"response.content_part.done\",\"output_index\":0,\"item_id\":\"msg_tmp_ilrlnvtd9l9\",\"content_index\":0,\"part\":{\"type\":\"output_text\",\"text\":\"Hello!\",\"annotations\":[],\"logprobs\":[]},\"sequence_number\":7}\n\ndata: {\"type\":\"response.output_item.done\",\"output_index\":0,\"item\":{\"id\":\"msg_tmp_ilrlnvtd9l9\",\"type\":\"message\",\"status\":\"completed\",\"role\":\"assistant\",\"content\":[{\"type\":\"output_text\",\"text\":\"Hello!\",\"annotations\":[],\"logprobs\":[]}]},\"sequence_number\":8}\n\ndata: {\"type\":\"response.completed\",\"response\":{\"id\":\"gen-1790743725-CVE8eRBKqzoJqJHXU1ur\",\"object\":\"response\",\"created_at\":1790743725,\"model\":\"openai/gpt-4o-mini\",\"status\":\"completed\",\"completed_at\":1790743727,\"output\":[{\"id\":\"msg_tmp_ilrlnvtd9l9\",\"type\":\"message\",\"role\":\"assistant\",\"status\":\"completed\",\"content\":[{\"type\":\"output_text\",\"text\":\"Hello!\",\"annotations\":[],\"logprobs\":[]}]}],\"error\":null,\"incomplete_details\":null,\"tools\":[],\"tool_choice\":\"auto\",\"parallel_tool_calls\":true,\"max_output_tokens\":40,\"temperature\":0,\"top_p\":1,\"presence_penalty\":0,\"frequency_penalty\":0,\"top_logprobs\":0,\"max_tool_calls\":null,\"metadata\":{},\"background\":false,\"previous_response_id\":null,\"service_tier\":\"default\",\"truncation\":\"disabled\",\"store\":false,\"instructions\":\"You are concise.\",\"text\":{\"format\":{\"type\":\"text\"}},\"reasoning\":null,\"safety_identifier\":null,\"prompt_cache_key\":null,\"prompt_cache_options\":null,\"usage\":{\"input_tokens\":21,\"input_tokens_details\":{\"cached_tokens\":0},\"output_tokens\":2,\"output_tokens_details\":{\"reasoning_tokens\":0},\"total_tokens\":23,\"cost\":0.00000435,\"is_byok\":false,\"cost_details\":{\"upstream_inference_cost\":0.00000435,\"upstream_inference_input_cost\":0.00000315,\"upstream_inference_output_cost\":0.0000012}}},\"sequence_number\":9}\n\ndata: [DONE]\n\n"
"body": "data: {\"type\":\"response.created\",\"response\":{\"id\":\"gen-1790785405-TibUw8IxcUdeh1aIMexG\",\"object\":\"response\",\"created_at\":1790785405,\"model\":\"openai/gpt-4o-mini\",\"status\":\"in_progress\",\"completed_at\":null,\"output\":[],\"error\":null,\"incomplete_details\":null,\"tools\":[],\"tool_choice\":\"auto\",\"parallel_tool_calls\":true,\"max_output_tokens\":40,\"temperature\":0,\"top_p\":1,\"presence_penalty\":0,\"frequency_penalty\":0,\"top_logprobs\":0,\"max_tool_calls\":null,\"metadata\":{},\"background\":false,\"previous_response_id\":null,\"service_tier\":\"auto\",\"truncation\":\"disabled\",\"store\":false,\"instructions\":\"You are concise.\",\"text\":{\"format\":{\"type\":\"text\"}},\"reasoning\":null,\"safety_identifier\":null,\"prompt_cache_key\":null,\"prompt_cache_options\":null,\"usage\":null},\"sequence_number\":0}\n\ndata: {\"type\":\"response.in_progress\",\"response\":{\"id\":\"gen-1790785405-TibUw8IxcUdeh1aIMexG\",\"object\":\"response\",\"created_at\":1790785405,\"model\":\"openai/gpt-4o-mini\",\"status\":\"in_progress\",\"completed_at\":null,\"output\":[],\"error\":null,\"incomplete_details\":null,\"tools\":[],\"tool_choice\":\"auto\",\"parallel_tool_calls\":true,\"max_output_tokens\":40,\"temperature\":0,\"top_p\":1,\"presence_penalty\":0,\"frequency_penalty\":0,\"top_logprobs\":0,\"max_tool_calls\":null,\"metadata\":{},\"background\":false,\"previous_response_id\":null,\"service_tier\":\"auto\",\"truncation\":\"disabled\",\"store\":false,\"instructions\":\"You are concise.\",\"text\":{\"format\":{\"type\":\"text\"}},\"reasoning\":null,\"safety_identifier\":null,\"prompt_cache_key\":null,\"prompt_cache_options\":null,\"usage\":null},\"sequence_number\":1}\n\ndata: {\"type\":\"response.output_item.added\",\"output_index\":0,\"item\":{\"id\":\"msg_tmp_2aq1aouoqhw\",\"type\":\"message\",\"status\":\"in_progress\",\"role\":\"assistant\",\"content\":[]},\"sequence_number\":2}\n\ndata: {\"type\":\"response.content_part.added\",\"output_index\":0,\"item_id\":\"msg_tmp_2aq1aouoqhw\",\"content_index\":0,\"part\":{\"type\":\"output_text\",\"text\":\"\",\"annotations\":[],\"logprobs\":[]},\"sequence_number\":3}\n\ndata: {\"type\":\"response.output_text.delta\",\"output_index\":0,\"item_id\":\"msg_tmp_2aq1aouoqhw\",\"content_index\":0,\"delta\":\"Hello\",\"logprobs\":[],\"sequence_number\":4}\n\ndata: {\"type\":\"response.output_text.delta\",\"output_index\":0,\"item_id\":\"msg_tmp_2aq1aouoqhw\",\"content_index\":0,\"delta\":\"!\",\"logprobs\":[],\"sequence_number\":5}\n\ndata: {\"type\":\"response.output_text.done\",\"output_index\":0,\"item_id\":\"msg_tmp_2aq1aouoqhw\",\"content_index\":0,\"text\":\"Hello!\",\"logprobs\":[],\"sequence_number\":6}\n\ndata: {\"type\":\"response.content_part.done\",\"output_index\":0,\"item_id\":\"msg_tmp_2aq1aouoqhw\",\"content_index\":0,\"part\":{\"type\":\"output_text\",\"text\":\"Hello!\",\"annotations\":[],\"logprobs\":[]},\"sequence_number\":7}\n\ndata: {\"type\":\"response.output_item.done\",\"output_index\":0,\"item\":{\"id\":\"msg_tmp_2aq1aouoqhw\",\"type\":\"message\",\"status\":\"completed\",\"role\":\"assistant\",\"content\":[{\"type\":\"output_text\",\"text\":\"Hello!\",\"annotations\":[],\"logprobs\":[]}]},\"sequence_number\":8}\n\ndata: {\"type\":\"response.completed\",\"response\":{\"id\":\"gen-1790785405-TibUw8IxcUdeh1aIMexG\",\"object\":\"response\",\"created_at\":1790785405,\"model\":\"openai/gpt-4o-mini\",\"status\":\"completed\",\"completed_at\":1790785406,\"output\":[{\"id\":\"msg_tmp_2aq1aouoqhw\",\"type\":\"message\",\"role\":\"assistant\",\"status\":\"completed\",\"content\":[{\"type\":\"output_text\",\"text\":\"Hello!\",\"annotations\":[],\"logprobs\":[]}]}],\"error\":null,\"incomplete_details\":null,\"tools\":[],\"tool_choice\":\"auto\",\"parallel_tool_calls\":true,\"max_output_tokens\":40,\"temperature\":0,\"top_p\":1,\"presence_penalty\":0,\"frequency_penalty\":0,\"top_logprobs\":0,\"max_tool_calls\":null,\"metadata\":{},\"background\":false,\"previous_response_id\":null,\"service_tier\":\"default\",\"truncation\":\"disabled\",\"store\":false,\"instructions\":\"You are concise.\",\"text\":{\"format\":{\"type\":\"text\"}},\"reasoning\":null,\"safety_identifier\":null,\"prompt_cache_key\":null,\"prompt_cache_options\":null,\"usage\":{\"input_tokens\":21,\"input_tokens_details\":{\"cached_tokens\":0},\"output_tokens\":2,\"output_tokens_details\":{\"reasoning_tokens\":0},\"total_tokens\":23,\"cost\":0.00000435,\"is_byok\":false,\"cost_details\":{\"upstream_inference_cost\":0.00000435,\"upstream_inference_input_cost\":0.00000315,\"upstream_inference_output_cost\":0.0000012}}},\"sequence_number\":9}\n\ndata: [DONE]\n\n"
}
}
]
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -1,9 +1,9 @@
import { expect } from "bun:test"
import { Effect } from "effect"
import { LLMEvent, LLMResponse } from "../../src/index.js"
import { LLM, LLMRequest, Message } from "../../src/index.js"
import { OpenRouter } from "../../src/providers/openrouter.js"
import { LLMClient } from "../../src/route.js"
import { recordedTests } from "../recorded-test.js"
import { expectWeatherToolLoop, goldenWeatherToolLoopRequest, runWeatherToolLoop } from "../recorded-scenarios.js"
const recorded = recordedTests({
prefix: "openrouter-responses",
@@ -13,24 +13,71 @@ const recorded = recordedTests({
})
recorded.effect.with(
"continues Meta reasoning through a tool loop",
"continues Meta reasoning through a namespaced tool loop",
{ cassette: "openrouter-responses/openrouter-muse-spark-1-3-tool-loop", tags: ["reasoning", "tool-loop"] },
() =>
Effect.gen(function* () {
const events = yield* runWeatherToolLoop(
goldenWeatherToolLoopRequest({
model: OpenRouter.configure({
apiKey: process.env.OPENROUTER_API_KEY ?? "fixture",
providerOptions: { reasoning: { effort: "minimal" } },
}).model("meta/muse-spark-1.3"),
maxTokens: 512,
const tool = {
name: "lookup",
description: "Look up a reference value",
inputSchema: {
type: "object",
properties: { query: { type: "string" } },
required: ["query"],
},
}
const request = LLM.request({
model: OpenRouter.configure({
apiKey: process.env.OPENROUTER_API_KEY ?? "fixture",
providerOptions: { reasoning: { effort: "minimal" } },
}).model("meta/muse-spark-1.3"),
prompt: "Call only crm.lookup once with query ping. Do not call inventory.lookup. Wait for the result.",
tools: ["crm", "inventory"].map((name) => ({ type: "namespace", name, tools: [tool] })),
generation: { maxTokens: 1536 },
})
const first = yield* LLMClient.generate(request)
expect(first.toolCalls).toMatchObject([{ name: "lookup", namespace: "crm", input: { query: "ping" } }])
const call = first.toolCalls[0]
if (!call) throw new Error("Expected a namespaced tool call")
const second = yield* LLMClient.generate(
LLMRequest.update(request, {
messages: [
...request.messages,
first.message,
Message.tool({ id: call.id, name: call.name, namespace: call.namespace, result: "pong" }),
Message.user("Do not call tools again. Reply exactly pong."),
],
}),
)
expectWeatherToolLoop(events)
expect(LLMResponse.text({ events: events.slice(events.findIndex(LLMEvent.is.stepFinish) + 1) }).trim()).toMatch(
/^Paris is sunny\.?$/,
)
expect(second.toolCalls).toHaveLength(0)
expect(second.text.trim()).toBe("pong")
}),
60_000,
)
recorded.effect(
"preserves native hosted-search events",
() =>
Effect.gen(function* () {
const response = yield* LLMClient.generate(
LLM.request({
model: OpenRouter.configure({
apiKey: process.env.OPENROUTER_API_KEY ?? "fixture",
providerOptions: { reasoning: { effort: "low" } },
}).model("x-ai/grok-4.3"),
prompt:
"Use web search once to find the official OpenRouter Responses API documentation. Reply with its URL.",
generation: { maxTokens: 1536 },
http: { body: { tools: [{ type: "web_search_preview", engine: "native", max_uses: 1 }], max_tool_calls: 1 } },
}),
)
expect(response.toolCalls).toContainEqual(expect.objectContaining({ name: "web_search", providerExecuted: true }))
expect(response.message.content).toContainEqual(
expect.objectContaining({ type: "tool-result", name: "web_search", providerExecuted: true }),
)
expect(response.text.trim().length).toBeGreaterThan(0)
}),
60_000,
)
+35 -10
View File
@@ -3,24 +3,48 @@ import { Effect } from "effect"
import { CacheHint, LLM, Message } from "../../src/index.js"
import { LLMClient } from "../../src/route.js"
import { compileRequest } from "../../src/route/client.js"
import * as OpenRouter from "../../src/providers/openrouter.js"
import { OpenRouter } from "../../src/providers/openrouter.js"
import { it } from "../lib/effect.js"
import { fixedResponse } from "../lib/http.js"
import { sseEvents } from "../lib/sse.js"
describe("OpenRouter", () => {
it.effect("routes OpenAI, xAI, and Meta models through the same generic Responses protocol", () =>
it.effect("routes OpenAI, xAI, and Meta models through the same namespace-aware Responses protocol", () =>
Effect.forEach(["openai/gpt-4o-mini", "x-ai/grok-4.3", "meta/muse-spark-1.3", "~x-ai/grok-latest"], (id) =>
Effect.gen(function* () {
const model = OpenRouter.configure({
apiKey: "test-key",
providerOptions: {
reasoning: { effort: "low", exclude: true },
text: { verbosity: "low" },
reasoning: { effort: "low", exclude: true, summary: "detailed" },
text: { verbosity: "low", format: { type: "json_schema", name: "result", schema: { type: "object" } } },
provider: { allow_fallbacks: false },
},
}).model(id)
const prepared = yield* compileRequest(LLM.request({ model, prompt: "Say hello." }))
const prepared = yield* compileRequest(
LLM.request({
model,
prompt: "Say hello.",
tools: [
{
type: "namespace",
name: "crm",
tools: [
{
type: "namespace",
name: "contacts",
tools: [
{
name: "lookup",
description: "Look up a contact",
inputSchema: { type: "object", properties: {} },
},
],
},
],
},
],
}),
)
expect(model.route.endpoint.baseURL).toBe("https://openrouter.ai/api/v1")
expect(prepared.route).toBe("openrouter-responses")
@@ -31,8 +55,9 @@ describe("OpenRouter", () => {
stream: true,
store: false,
include: ["reasoning.encrypted_content"],
reasoning: { effort: "low", exclude: true },
text: { verbosity: "low" },
reasoning: { effort: "low", exclude: true, summary: "detailed" },
text: { verbosity: "low", format: { type: "json_schema", name: "result", schema: { type: "object" } } },
tools: [{ type: "namespace", name: "crm", tools: [{ type: "function", name: "contacts_lookup" }] }],
provider: { allow_fallbacks: false },
})
}),
@@ -92,17 +117,17 @@ describe("OpenRouter", () => {
Effect.gen(function* () {
const openrouter = OpenRouter.configure({
apiKey: "test-key",
providerOptions: { provider: { allow_fallbacks: false }, reasoning: { effort: "low" } },
providerOptions: { provider: { allow_fallbacks: false }, reasoning: { effort: "low", summary: "detailed" } },
})
const messages = [Message.effort({ previous: "high", effort: "low" }), Message.user("Hello")]
const response = yield* compileRequest(LLM.request({ model: openrouter.model("openai/gpt-6-luna"), messages }))
const response = yield* compileRequest(LLM.request({ model: openrouter.model("openai/gpt-6.1-sol"), messages }))
const message = yield* compileRequest(
LLM.request({ model: openrouter.model("anthropic/claude-fable-5.1"), messages }),
)
expect(response.body).toMatchObject({
provider: { allow_fallbacks: false },
reasoning: { effort: "high" },
reasoning: { effort: "high", summary: "detailed" },
input: [{ type: "configuration_update", reasoning: { effort: "low" } }, { role: "user" }],
})
expect(message.body).toMatchObject({