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.
|
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),
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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 } : {}),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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({
|
||||||
|
|||||||
+2
-2
@@ -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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|||||||
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 { 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,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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({
|
||||||
|
|||||||
Reference in New Issue
Block a user