mirror of
https://gitee.com/zhangsken/opencode.git
synced 2026-10-16 02:24:10 +08:00
refactor(ai): reuse shared OpenRouter Responses implementation
This commit is contained in:
@@ -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),
|
||||
})
|
||||
|
||||
@@ -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 } : {}),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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({
|
||||
|
||||
+2
-2
@@ -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"
|
||||
}
|
||||
}
|
||||
]
|
||||
|
||||
Vendored
+2
-2
File diff suppressed because one or more lines are too long
Vendored
+4
-4
File diff suppressed because one or more lines are too long
Vendored
+4
-4
File diff suppressed because one or more lines are too long
Vendored
+4
-4
File diff suppressed because one or more lines are too long
Vendored
+5
-5
File diff suppressed because one or more lines are too long
packages/ai/test/fixtures/recordings/openrouter-responses/preserves-native-hosted-search-events.json
Vendored
+32
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,
|
||||
)
|
||||
|
||||
@@ -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({
|
||||
|
||||
Reference in New Issue
Block a user