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. 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 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 Anthropic Messages; other families use Chat Completions. All three Responses families share the existing OpenAI-shaped
Responses serializer and stream parser; there are no per-vendor OpenRouter protocols. Leading-`~` model aliases follow Responses implementation, including namespace tools and hosted-tool events; there are no per-vendor OpenRouter
the same routing. Explicit `openrouter.chat(id)`, `.responses(id)`, and `.messages(id)` selectors are available for protocols. Leading-`~` model aliases follow the same routing. Explicit `openrouter.chat(id)`, `.responses(id)`, and
API-specific integrations. `.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. 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`. 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 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, ...OpenAIResponsesCoreFields,
stream: Schema.Literal(true), stream: Schema.Literal(true),
}) })
+49 -40
View File
@@ -2,9 +2,7 @@ export * as OpenRouterWire from "./openrouter.js"
import { Option, Schema } from "effect" import { Option, Schema } from "effect"
import { LLMRequest, Message, type ContentPart, type ReasoningPart } from "../../schema/index.js" import { LLMRequest, Message, type ContentPart, type ReasoningPart } from "../../schema/index.js"
import { resolveEffortUpdates } from "../../effort-updates.js" import type { OpenAIResponses } from "../openai-responses.js"
import type { OpenResponses } from "../open-responses.js"
import { OpenResponsesOptions } from "./open-responses-options.js"
import { isRecord, ProviderShared } from "../shared.js" import { isRecord, ProviderShared } from "../shared.js"
const ReplayDetail = Schema.Struct({ 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. // OpenRouter's generic Responses API supports chronological effort updates only on eligible models.
export function supportsEffortUpdates(request: LLMRequest) { 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 (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.providerOptions?.reasoning)) return false
if (Schema.is(Schema.Struct({ mode: Schema.Literal("pro") }))(request.http?.body?.reasoning)) return false if (Schema.is(Schema.Struct({ mode: Schema.Literal("pro") }))(request.http?.body?.reasoning)) return false
return ( 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) ? fitReasoning(options.reasoning, request.generation?.maxTokens)
: undefined : undefined
const disabled = reasoning?.enabled === false || reasoning?.effort === "none" 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, { return LLMRequest.update(request, {
providerOptions: providerOptions:
format === "responses" format === "responses"
? { ? {
...options, ...options,
reasoningEffort: updates.effort, reasoningEffort:
options.reasoningEffort ?? (typeof reasoning?.effort === "string" ? reasoning.effort : undefined),
} }
: { : {
...options, ...options,
@@ -58,7 +54,7 @@ export function nativeRequest(request: LLMRequest, format: "responses" | "messag
return { type: "adaptive", ...(reasoning.exclude === true ? { display: "omitted" } : {}) } return { type: "adaptive", ...(reasoning.exclude === true ? { display: "omitted" } : {}) }
})(), })(),
}, },
messages: (format === "responses" ? updates.request : request).messages.map((message) => { messages: request.messages.map((message) => {
if ( if (
!message.content.some( !message.content.some(
(part) => part.type === "reasoning" && part.providerMetadata?.openrouter?.reasoningDetails !== undefined, (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) => { const blocks = replay.flatMap<ReasoningPart>((detail) => {
if (detail.type === "reasoning.text" && detail.signature) const metadata = (() => {
return [ if (detail.type === "reasoning.text" && detail.signature) return { signature: detail.signature }
{ if (detail.type === "reasoning.encrypted" && detail.data) return { redactedData: detail.data }
...part, })()
text: detail.text ?? part.text, if (!metadata) return []
providerMetadata: { return [
...part.providerMetadata, {
openrouter: { ...part.providerMetadata?.openrouter, signature: detail.signature }, ...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] 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">>( export function responsesOptions(request: LLMRequest, body: OpenAIResponses.OpenAIResponsesBody) {
request: LLMRequest,
body: Body,
) {
const { usage: _, ...options } = bodyOptions(request.providerOptions, request.generation?.maxTokens) const { usage: _, ...options } = bodyOptions(request.providerOptions, request.generation?.maxTokens)
return { return {
...options, ...options,
...body, ...body,
store: false as const, store: false as const,
// Native lowering may freeze effort at the history baseline; keep it instead of the current effort. // 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) => { export const bodyOptions = (input: unknown, maxTokens: number | undefined) => {
const openrouter = isRecord(input) ? input : {} const openrouter = isRecord(input) ? input : {}
const { usage, models, provider, plugins, web_search_options, debug, user, reasoning, promptCacheKey, ...options } = const {
openrouter usage,
models,
provider,
plugins,
web_search_options,
debug,
user,
reasoning,
text,
promptCacheKey,
...options
} = openrouter
return { return {
...options, ...options,
...(usage === undefined || usage === true ...(usage === undefined || usage === true
@@ -159,6 +167,7 @@ export const bodyOptions = (input: unknown, maxTokens: number | undefined) => {
...(isRecord(debug) ? { debug } : {}), ...(isRecord(debug) ? { debug } : {}),
...(typeof user === "string" ? { user } : {}), ...(typeof user === "string" ? { user } : {}),
...(isRecord(reasoning) ? { reasoning: fitReasoning(reasoning, maxTokens) } : {}), ...(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 type { ProviderPackage } from "../provider-package.js"
import { SystemOne } from "../experimental/system-one.js" import { SystemOne } from "../experimental/system-one.js"
import { OpenAIChat } from "../protocols/openai-chat.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 { AnthropicMessages } from "../protocols/anthropic-messages.js"
import { Framing } from "../route/framing.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 { newBreakpoints, ttlBucket } from "../protocols/utils/cache.js"
import { OpenRouterWire } from "../protocols/utils/openrouter.js" import { OpenRouterWire } from "../protocols/utils/openrouter.js"
@@ -58,6 +58,7 @@ export interface OpenRouterOptions {
readonly plugins?: ReadonlyArray<OpenRouterPlugin> readonly plugins?: ReadonlyArray<OpenRouterPlugin>
readonly provider?: OpenRouterProviderRouting readonly provider?: OpenRouterProviderRouting
readonly reasoning?: Readonly<{ readonly reasoning?: Readonly<{
[key: string]: unknown
enabled?: boolean enabled?: boolean
exclude?: boolean exclude?: boolean
effort?: OpenString<"none" | "minimal" | "low" | "medium" | "high" | "xhigh" | "max"> 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> 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( const OpenRouterResponsesBody = Schema.StructWithRest(
Schema.Struct({ Schema.Struct({
...OpenResponses.coreFields, ...OpenAIResponses.OpenAIResponsesBody.fields,
input: Schema.Array(Schema.Union([OpenResponses.InputItem, OpenResponses.ConfigurationUpdate])),
stream: Schema.Literal(true),
store: Schema.Literal(false), 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, [ const OpenRouterMessagesBody = Schema.StructWithRest(AnthropicMessages.AnthropicMessagesBody, [JsonObject])
Schema.Record(Schema.String, Schema.Unknown),
])
type OpenRouterMessagesBody = Schema.Schema.Type<typeof OpenRouterMessagesBody> type OpenRouterMessagesBody = Schema.Schema.Type<typeof OpenRouterMessagesBody>
export const protocol = Protocol.make({ export const protocol = Protocol.make({
@@ -161,19 +149,15 @@ export const protocol = Protocol.make({
}) })
const responsesProtocol = Protocol.make({ const responsesProtocol = Protocol.make({
...OpenResponses.protocol, ...OpenAIResponses.protocol,
id: "openrouter-responses", id: "openrouter-responses",
supportsEffortUpdates: OpenRouterWire.supportsEffortUpdates, supportsEffortUpdates: OpenRouterWire.supportsEffortUpdates,
body: { body: {
schema: OpenRouterResponsesBody, schema: OpenRouterResponsesBody,
from: (request) => from: (request) =>
OpenResponses.fromRequestWithAdapter(OpenRouterWire.nativeRequest(request, "responses"), { OpenAIResponses.protocol.body
id: "openrouter-responses", .from(OpenRouterWire.nativeRequest(request, "responses"))
name: "OpenRouter Responses", .pipe(Effect.map((body) => OpenRouterWire.responsesOptions(request, body))),
}).pipe(
Effect.map((body) => OpenRouterWire.responsesOptions(request, body)),
Effect.flatMap(ProviderShared.validateWith(Schema.decodeUnknownEffect(OpenRouterResponsesBody))),
),
}, },
}) })
const messagesProtocol = Protocol.make({ const messagesProtocol = Protocol.make({
@@ -12,7 +12,7 @@
"golden" "golden"
], ],
"name": "openrouter-responses/openrouter-gpt-4o-mini-text", "name": "openrouter-responses/openrouter-gpt-4o-mini-text",
"recordedAt": "2026-09-30T04:48:47.207Z" "recordedAt": "2026-09-30T16:23:26.190Z"
}, },
"interactions": [ "interactions": [
{ {
@@ -30,7 +30,7 @@
"headers": { "headers": {
"content-type": "text/event-stream" "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 { expect } from "bun:test"
import { Effect } from "effect" 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 { OpenRouter } from "../../src/providers/openrouter.js"
import { LLMClient } from "../../src/route.js"
import { recordedTests } from "../recorded-test.js" import { recordedTests } from "../recorded-test.js"
import { expectWeatherToolLoop, goldenWeatherToolLoopRequest, runWeatherToolLoop } from "../recorded-scenarios.js"
const recorded = recordedTests({ const recorded = recordedTests({
prefix: "openrouter-responses", prefix: "openrouter-responses",
@@ -13,24 +13,71 @@ const recorded = recordedTests({
}) })
recorded.effect.with( 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"] }, { cassette: "openrouter-responses/openrouter-muse-spark-1-3-tool-loop", tags: ["reasoning", "tool-loop"] },
() => () =>
Effect.gen(function* () { Effect.gen(function* () {
const events = yield* runWeatherToolLoop( const tool = {
goldenWeatherToolLoopRequest({ name: "lookup",
model: OpenRouter.configure({ description: "Look up a reference value",
apiKey: process.env.OPENROUTER_API_KEY ?? "fixture", inputSchema: {
providerOptions: { reasoning: { effort: "minimal" } }, type: "object",
}).model("meta/muse-spark-1.3"), properties: { query: { type: "string" } },
maxTokens: 512, 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(second.toolCalls).toHaveLength(0)
expect(LLMResponse.text({ events: events.slice(events.findIndex(LLMEvent.is.stepFinish) + 1) }).trim()).toMatch( expect(second.text.trim()).toBe("pong")
/^Paris is sunny\.?$/, }),
) 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, 60_000,
) )
+35 -10
View File
@@ -3,24 +3,48 @@ import { Effect } from "effect"
import { CacheHint, LLM, Message } from "../../src/index.js" import { CacheHint, LLM, Message } from "../../src/index.js"
import { LLMClient } from "../../src/route.js" import { LLMClient } from "../../src/route.js"
import { compileRequest } from "../../src/route/client.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 { it } from "../lib/effect.js"
import { fixedResponse } from "../lib/http.js" import { fixedResponse } from "../lib/http.js"
import { sseEvents } from "../lib/sse.js" import { sseEvents } from "../lib/sse.js"
describe("OpenRouter", () => { 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.forEach(["openai/gpt-4o-mini", "x-ai/grok-4.3", "meta/muse-spark-1.3", "~x-ai/grok-latest"], (id) =>
Effect.gen(function* () { Effect.gen(function* () {
const model = OpenRouter.configure({ const model = OpenRouter.configure({
apiKey: "test-key", apiKey: "test-key",
providerOptions: { providerOptions: {
reasoning: { effort: "low", exclude: true }, reasoning: { effort: "low", exclude: true, summary: "detailed" },
text: { verbosity: "low" }, text: { verbosity: "low", format: { type: "json_schema", name: "result", schema: { type: "object" } } },
provider: { allow_fallbacks: false }, provider: { allow_fallbacks: false },
}, },
}).model(id) }).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(model.route.endpoint.baseURL).toBe("https://openrouter.ai/api/v1")
expect(prepared.route).toBe("openrouter-responses") expect(prepared.route).toBe("openrouter-responses")
@@ -31,8 +55,9 @@ describe("OpenRouter", () => {
stream: true, stream: true,
store: false, store: false,
include: ["reasoning.encrypted_content"], include: ["reasoning.encrypted_content"],
reasoning: { effort: "low", exclude: true }, reasoning: { effort: "low", exclude: true, summary: "detailed" },
text: { verbosity: "low" }, 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 }, provider: { allow_fallbacks: false },
}) })
}), }),
@@ -92,17 +117,17 @@ describe("OpenRouter", () => {
Effect.gen(function* () { Effect.gen(function* () {
const openrouter = OpenRouter.configure({ const openrouter = OpenRouter.configure({
apiKey: "test-key", 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 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( const message = yield* compileRequest(
LLM.request({ model: openrouter.model("anthropic/claude-fable-5.1"), messages }), LLM.request({ model: openrouter.model("anthropic/claude-fable-5.1"), messages }),
) )
expect(response.body).toMatchObject({ expect(response.body).toMatchObject({
provider: { allow_fallbacks: false }, provider: { allow_fallbacks: false },
reasoning: { effort: "high" }, reasoning: { effort: "high", summary: "detailed" },
input: [{ type: "configuration_update", reasoning: { effort: "low" } }, { role: "user" }], input: [{ type: "configuration_update", reasoning: { effort: "low" } }, { role: "user" }],
}) })
expect(message.body).toMatchObject({ expect(message.body).toMatchObject({