From 6aaaac27d061985e4fcc2e54317694825da05759 Mon Sep 17 00:00:00 2001 From: Aiden Cline Date: Sat, 23 May 2026 15:42:28 -0500 Subject: [PATCH] fix(llm): preserve native provider options --- .../llm/src/protocols/anthropic-messages.ts | 76 +++++++--- packages/llm/src/protocols/openai-chat.ts | 52 +++++-- .../src/protocols/openai-compatible-chat.ts | 72 +++++++++- .../llm/src/protocols/openai-responses.ts | 92 +++++++++---- .../llm/src/protocols/utils/openai-options.ts | 93 +++++++------ .../src/protocols/utils/provider-options.ts | 34 +++++ .../test/provider/anthropic-messages.test.ts | 74 ++++++++++ .../provider/openai-compatible-chat.test.ts | 130 +++++++++++++++++- .../test/provider/openai-responses.test.ts | 24 ++++ .../src/session/llm/native-request.ts | 4 +- .../session/native-zen-tool-loop.json | 4 +- .../opencode/test/session/llm-native.test.ts | 80 +++++++++++ 12 files changed, 629 insertions(+), 106 deletions(-) create mode 100644 packages/llm/src/protocols/utils/provider-options.ts diff --git a/packages/llm/src/protocols/anthropic-messages.ts b/packages/llm/src/protocols/anthropic-messages.ts index 234ccd5ba..24688cff9 100644 --- a/packages/llm/src/protocols/anthropic-messages.ts +++ b/packages/llm/src/protocols/anthropic-messages.ts @@ -20,6 +20,7 @@ import { import { JsonObject, optionalArray, optionalNull, ProviderShared } from "./shared" import * as Cache from "./utils/cache" import { Lifecycle } from "./utils/lifecycle" +import { ProviderOptions } from "./utils/provider-options" import { ToolStream } from "./utils/tool-stream" const ADAPTER = "anthropic-messages" @@ -136,6 +137,7 @@ const AnthropicTool = Schema.Struct({ description: Schema.String, input_schema: JsonObject, cache_control: Schema.optional(AnthropicCacheControl), + eager_input_streaming: Schema.optional(Schema.Boolean), }) type AnthropicTool = Schema.Schema.Type @@ -144,10 +146,10 @@ const AnthropicToolChoice = Schema.Union([ Schema.Struct({ type: Schema.tag("tool"), name: Schema.String }), ]) -const AnthropicThinking = Schema.Struct({ - type: Schema.tag("enabled"), - budget_tokens: Schema.Number, -}) +// Anthropic accepts several `thinking` shapes (enabled with `budget_tokens`, +// adaptive with optional `display`, and disabled). The body schema permits the +// full union so explicit lowering can pick the correct fields per model. +const AnthropicThinkingBody = Schema.Record(Schema.String, Schema.Unknown) const AnthropicBodyFields = { model: Schema.String, @@ -161,9 +163,12 @@ const AnthropicBodyFields = { top_p: Schema.optional(Schema.Number), top_k: Schema.optional(Schema.Number), stop_sequences: optionalArray(Schema.String), - thinking: Schema.optional(AnthropicThinking), + thinking: Schema.optional(AnthropicThinkingBody), } -const AnthropicMessagesBody = Schema.Struct(AnthropicBodyFields) +// Unknown provider options pass through verbatim with top-level keys snake-cased. +const AnthropicMessagesBody = Schema.StructWithRest(Schema.Struct(AnthropicBodyFields), [ + Schema.Record(Schema.String, Schema.Any), +]) export type AnthropicMessagesBody = Schema.Schema.Type const AnthropicUsage = Schema.Struct({ @@ -254,11 +259,16 @@ const signatureFromMetadata = (metadata: ProviderMetadata | undefined): string | return typeof anthropic.signature === "string" ? anthropic.signature : undefined } -const lowerTool = (breakpoints: Cache.Breakpoints, tool: ToolDefinition): AnthropicTool => ({ +const lowerTool = ( + breakpoints: Cache.Breakpoints, + tool: ToolDefinition, + eagerInputStreaming: boolean | undefined, +): AnthropicTool => ({ name: tool.name, description: tool.description, input_schema: tool.inputSchema, cache_control: cacheControl(breakpoints, tool.cache), + eager_input_streaming: eagerInputStreaming ? true : undefined, }) const lowerToolChoice = (toolChoice: NonNullable) => @@ -413,24 +423,46 @@ const lowerMessages = Effect.fn("AnthropicMessages.lowerMessages")(function* ( return messages }) -const anthropicOptions = (request: LLMRequest) => request.providerOptions?.anthropic +// Typed AI SDK Anthropic options. Mirrors the subset of +// `anthropicLanguageModelOptions` that opencode's provider transform actually +// emits today (see `packages/opencode/src/provider/transform.ts`). Unknown +// keys flow through the index signature and pass through to the wire body +// with their top-level key snake-cased. +type AnthropicEffort = "low" | "medium" | "high" | "xhigh" | "max" +type AnthropicThinking = + | { readonly type: "enabled"; readonly budgetTokens?: number; readonly budget_tokens?: number } + | { readonly type: "adaptive"; readonly display?: "omitted" | "summarized" } + | { readonly type: "disabled" } -const lowerThinking = Effect.fn("AnthropicMessages.lowerThinking")(function* (request: LLMRequest) { - const thinking = anthropicOptions(request)?.thinking - if (!ProviderShared.isRecord(thinking) || thinking.type !== "enabled") return undefined - const budget = - typeof thinking.budgetTokens === "number" - ? thinking.budgetTokens - : typeof thinking.budget_tokens === "number" - ? thinking.budget_tokens - : undefined - if (budget === undefined) return yield* invalid("Anthropic thinking provider option requires budgetTokens") +interface AnthropicOptions { + readonly thinking?: AnthropicThinking + readonly effort?: AnthropicEffort + readonly toolStreaming?: boolean + readonly [extra: string]: unknown +} + +const ANTHROPIC_KNOWN_KEYS: ReadonlySet = new Set(["thinking", "effort", "toolStreaming"]) + +const lowerThinking = (thinking: AnthropicOptions["thinking"]) => { + if (thinking === undefined) return undefined + if (thinking.type === "disabled") return undefined + if (thinking.type === "adaptive") { + return { type: "adaptive" as const, ...(thinking.display ? { display: thinking.display } : {}) } + } + const budget = thinking.budgetTokens ?? thinking.budget_tokens + if (budget === undefined) return undefined return { type: "enabled" as const, budget_tokens: budget } -}) +} const fromRequest = Effect.fn("AnthropicMessages.fromRequest")(function* (request: LLMRequest) { const toolChoice = request.toolChoice ? yield* lowerToolChoice(request.toolChoice) : undefined const generation = request.generation + const options = ProviderOptions.merge(request, ["anthropic"]) as AnthropicOptions + // AI SDK's `toolStreaming` controls per-tool `eager_input_streaming`. opencode + // sets `toolStreaming: false` for non-Claude models routed through + // `@ai-sdk/anthropic`; otherwise the field is left unset so the provider + // applies its own default. + const eagerInputStreaming = options.toolStreaming === true // Allocate the 4-breakpoint budget in invalidation order: tools → system → // messages. Tools live highest in the cache hierarchy, so when callers // over-mark we keep their tool hints and shed the message-tail ones first. @@ -438,7 +470,7 @@ const fromRequest = Effect.fn("AnthropicMessages.fromRequest")(function* (reques const tools = request.tools.length === 0 || request.toolChoice?.type === "none" ? undefined - : request.tools.map((tool) => lowerTool(breakpoints, tool)) + : request.tools.map((tool) => lowerTool(breakpoints, tool, eagerInputStreaming)) const system = request.system.length === 0 ? undefined @@ -454,6 +486,7 @@ const fromRequest = Effect.fn("AnthropicMessages.fromRequest")(function* (reques ) } return { + ...ProviderOptions.passthrough(options, ANTHROPIC_KNOWN_KEYS), model: request.model.id, system, messages, @@ -465,7 +498,8 @@ const fromRequest = Effect.fn("AnthropicMessages.fromRequest")(function* (reques top_p: generation?.topP, top_k: generation?.topK, stop_sequences: generation?.stop, - thinking: yield* lowerThinking(request), + thinking: lowerThinking(options.thinking), + ...(options.effort !== undefined ? { effort: options.effort } : {}), } }) diff --git a/packages/llm/src/protocols/openai-chat.ts b/packages/llm/src/protocols/openai-chat.ts index 6a85c37d5..30e8479b4 100644 --- a/packages/llm/src/protocols/openai-chat.ts +++ b/packages/llm/src/protocols/openai-chat.ts @@ -15,6 +15,7 @@ import { } from "../schema" import { isRecord, JsonObject, optionalArray, optionalNull, ProviderShared } from "./shared" import { OpenAIOptions } from "./utils/openai-options" +import { ProviderOptions } from "./utils/provider-options" import { Lifecycle } from "./utils/lifecycle" import { ToolStream } from "./utils/tool-stream" @@ -50,6 +51,10 @@ const OpenAIChatAssistantToolCall = Schema.Struct({ }) type OpenAIChatAssistantToolCall = Schema.Schema.Type +// `reasoning_content` is a plain string per DeepSeek/OpenAI-compatible spec. +// `reasoning_details` is an OpenRouter-style array of typed reasoning objects +// (summary / encrypted / text). We accept the structured payload as-is so it +// round-trips verbatim to the provider on continuation requests. const OpenAIChatMessage = Schema.Union([ Schema.Struct({ role: Schema.Literal("system"), content: Schema.String }), Schema.Struct({ role: Schema.Literal("user"), content: Schema.String }), @@ -58,6 +63,7 @@ const OpenAIChatMessage = Schema.Union([ content: Schema.NullOr(Schema.String), tool_calls: optionalArray(OpenAIChatAssistantToolCall), reasoning_content: Schema.optional(Schema.String), + reasoning_details: Schema.optional(Schema.Array(Schema.Record(Schema.String, Schema.Unknown))), }), Schema.Struct({ role: Schema.Literal("tool"), tool_call_id: Schema.String, content: Schema.String }), ]).pipe(Schema.toTaggedUnion("role")) @@ -79,7 +85,7 @@ export const bodyFields = { stream: Schema.Literal(true), stream_options: Schema.optional(Schema.Struct({ include_usage: Schema.Boolean })), store: Schema.optional(Schema.Boolean), - reasoning_effort: Schema.optional(OpenAIOptions.OpenAIReasoningEffort), + reasoning_effort: Schema.optional(Schema.String), max_tokens: Schema.optional(Schema.Number), temperature: Schema.optional(Schema.Number), top_p: Schema.optional(Schema.Number), @@ -88,7 +94,7 @@ export const bodyFields = { seed: Schema.optional(Schema.Number), stop: optionalArray(Schema.String), } -const OpenAIChatBody = Schema.Struct(bodyFields) +const OpenAIChatBody = Schema.StructWithRest(Schema.Struct(bodyFields), [Schema.Record(Schema.String, Schema.Any)]) export type OpenAIChatBody = Schema.Schema.Type // ============================================================================= @@ -125,9 +131,16 @@ const OpenAIChatToolCallDelta = Schema.Struct({ }) type OpenAIChatToolCallDelta = Schema.Schema.Type +// Streaming reasoning fields. `reasoning_content` (DeepSeek) and `reasoning` +// (AI SDK fallback) are strings; `reasoning_details` (OpenRouter) is an array +// of typed reasoning detail objects. We surface their plaintext via reasoning +// deltas and preserve the structured array for downstream round-trip. +const OpenAIChatReasoningDetail = Schema.Record(Schema.String, Schema.Unknown) const OpenAIChatDelta = Schema.Struct({ content: optionalNull(Schema.String), reasoning_content: optionalNull(Schema.String), + reasoning: optionalNull(Schema.String), + reasoning_details: optionalNull(Schema.Array(OpenAIChatReasoningDetail)), tool_calls: optionalNull(Schema.Array(OpenAIChatToolCallDelta)), }) @@ -188,6 +201,16 @@ const lowerToolCall = (part: ToolCallPart): OpenAIChatAssistantToolCall => ({ const openAICompatibleReasoningContent = (native: unknown) => isRecord(native) && typeof native.reasoning_content === "string" ? native.reasoning_content : undefined +// `reasoning_details` rounds-trips the OpenRouter structured array. Accept the +// array shape as canonical; tolerate a string for legacy callers that already +// flattened it. +const openAICompatibleReasoningDetails = (native: unknown) => { + if (!isRecord(native)) return undefined + const value = native.reasoning_details + if (Array.isArray(value)) return value as ReadonlyArray> + return undefined +} + const lowerUserMessage = Effect.fn("OpenAIChat.lowerUserMessage")(function* (message: OpenAIChatRequestMessage) { const content: TextPart[] = [] for (const part of message.content) { @@ -220,6 +243,7 @@ const lowerAssistantMessage = Effect.fn("OpenAIChat.lowerAssistantMessage")(func content: content.length === 0 ? null : ProviderShared.joinText(content), tool_calls: toolCalls.length === 0 ? undefined : toolCalls, reasoning_content: openAICompatibleReasoningContent(message.native?.openaiCompatible), + reasoning_details: openAICompatibleReasoningDetails(message.native?.openaiCompatible), } }) @@ -246,13 +270,14 @@ const lowerMessages = Effect.fn("OpenAIChat.lowerMessages")(function* (request: }) const lowerOptions = Effect.fn("OpenAIChat.lowerOptions")(function* (request: LLMRequest) { - const store = OpenAIOptions.store(request) - const reasoningEffort = OpenAIOptions.reasoningEffort(request) - if (reasoningEffort && !OpenAIOptions.isReasoningEffort(reasoningEffort)) - return yield* invalid(`OpenAI Chat does not support reasoning effort ${reasoningEffort}`) + const options = OpenAIOptions.options(request) + const effort = options.reasoningEffort + if (effort !== undefined && !OpenAIOptions.isReasoningEffort(effort)) + return yield* invalid(`OpenAI Chat does not support reasoning effort ${effort}`) return { - ...(store !== undefined ? { store } : {}), - ...(reasoningEffort ? { reasoning_effort: reasoningEffort } : {}), + ...ProviderOptions.passthrough(options, OpenAIOptions.KNOWN_KEYS), + ...(options.store !== undefined ? { store: options.store } : {}), + ...(effort !== undefined ? { reasoning_effort: effort } : {}), } }) @@ -325,8 +350,15 @@ const step = (state: ParserState, event: OpenAIChatEvent) => let lifecycle = state.lifecycle - if (delta?.reasoning_content) - lifecycle = Lifecycle.reasoningDelta(lifecycle, events, "reasoning-0", delta.reasoning_content) + // OpenRouter-style `reasoning_details` ships an array of typed reasoning + // objects (summary / text / encrypted). Concatenate the plaintext fields + // into the reasoning delta stream; the structured array is preserved on + // the assistant message for round-trip via `providerMetadata`. + const detailText = (delta?.reasoning_details ?? []) + .map((detail) => (typeof detail.text === "string" ? detail.text : typeof detail.summary === "string" ? detail.summary : "")) + .join("") + const reasoning = delta?.reasoning_content ?? delta?.reasoning ?? (detailText.length > 0 ? detailText : undefined) + if (reasoning) lifecycle = Lifecycle.reasoningDelta(lifecycle, events, "reasoning-0", reasoning) if (delta?.content) lifecycle = Lifecycle.textDelta(lifecycle, events, "text-0", delta.content) diff --git a/packages/llm/src/protocols/openai-compatible-chat.ts b/packages/llm/src/protocols/openai-compatible-chat.ts index ce3f0a83d..3d494b42a 100644 --- a/packages/llm/src/protocols/openai-compatible-chat.ts +++ b/packages/llm/src/protocols/openai-compatible-chat.ts @@ -1,22 +1,84 @@ +import { Effect, Schema } from "effect" import { Route, type RouteRoutedModelInput } from "../route/client" import { Endpoint } from "../route/endpoint" import { Framing } from "../route/framing" +import { Protocol } from "../route/protocol" +import type { LLMRequest } from "../schema" +import { ProviderOptions } from "./utils/provider-options" import * as OpenAIChat from "./openai-chat" const ADAPTER = "openai-compatible-chat" export type OpenAICompatibleChatModelInput = RouteRoutedModelInput +const OpenAICompatibleChatBody = Schema.StructWithRest( + Schema.Struct({ ...OpenAIChat.bodyFields, reasoning_effort: Schema.optional(Schema.String) }), + [Schema.Record(Schema.String, Schema.Any)], +) +export type OpenAICompatibleChatBody = Schema.Schema.Type + +// Typed AI SDK `@ai-sdk/openai-compatible` options. Known keys are lowered +// explicitly; everything else passes through to the wire body with its +// top-level key snake-cased. +interface CompatibleOptions { + readonly user?: string + readonly reasoningEffort?: string + readonly textVerbosity?: string + readonly strictJsonSchema?: boolean + readonly [extra: string]: unknown +} +const COMPATIBLE_KNOWN_KEYS: ReadonlySet = new Set([ + "user", + "reasoningEffort", + "textVerbosity", + "strictJsonSchema", +]) + +// Match AI SDK `@ai-sdk/openai-compatible` option resolution: the deprecated +// `openai-compatible` alias, the canonical `openaiCompatible` key, the raw +// provider name (dot-split so e.g. `opencode.internal` matches `opencode`), +// and its camelCase variant. Later sources override earlier ones. +const bodyOptions = (request: LLMRequest) => { + const provider = String(request.model.provider).split(".")[0] + const camel = provider.replace(/[_-]([a-z])/g, (_, value: string) => value.toUpperCase()) + const options = ProviderOptions.merge(request, [ + "openai-compatible", + "openaiCompatible", + provider, + camel, + ]) as CompatibleOptions + return { + ...ProviderOptions.passthrough(options, COMPATIBLE_KNOWN_KEYS), + ...(options.user !== undefined ? { user: options.user } : {}), + ...(options.reasoningEffort !== undefined ? { reasoning_effort: options.reasoningEffort } : {}), + ...(options.textVerbosity !== undefined ? { verbosity: options.textVerbosity } : {}), + } +} + +export const protocol = Protocol.make({ + id: ADAPTER, + body: { + schema: OpenAICompatibleChatBody, + // Drop providerOptions before delegating so OpenAI Chat's OpenAI-only + // option validation does not reject compatible-route requests whose + // provider id happens to be `openai` or use extended reasoning efforts. + from: (request) => + OpenAIChat.protocol.body + .from({ ...request, providerOptions: undefined }) + .pipe(Effect.map((body) => ({ ...body, ...bodyOptions(request) }))), + }, + stream: OpenAIChat.protocol.stream, +}) + /** * Route for non-OpenAI providers that expose an OpenAI Chat-compatible - * `/chat/completions` endpoint. Reuses `OpenAIChat.protocol` end-to-end and - * overrides only the route id so providers can be resolved per-family without - * colliding with native OpenAI. Provider helpers configure the route endpoint - * before model selection. + * `/chat/completions` endpoint. Reuses OpenAI Chat streaming behavior while + * allowing compatible providers to pass through additional request-body + * options such as `enable_thinking` and extended reasoning efforts. */ export const route = Route.make({ id: ADAPTER, - protocol: OpenAIChat.protocol, + protocol, endpoint: Endpoint.path("/chat/completions"), framing: Framing.sse, }) diff --git a/packages/llm/src/protocols/openai-responses.ts b/packages/llm/src/protocols/openai-responses.ts index 88c65d187..8cba361fb 100644 --- a/packages/llm/src/protocols/openai-responses.ts +++ b/packages/llm/src/protocols/openai-responses.ts @@ -19,6 +19,7 @@ import { } from "../schema" import { JsonObject, optionalArray, optionalNull, ProviderShared } from "./shared" import { OpenAIOptions } from "./utils/openai-options" +import { ProviderOptions } from "./utils/provider-options" import { Lifecycle } from "./utils/lifecycle" import { ToolStream } from "./utils/tool-stream" @@ -111,12 +112,23 @@ const OpenAIResponsesCoreFields = { tools: optionalArray(OpenAIResponsesTool), tool_choice: Schema.optional(OpenAIResponsesToolChoice), store: Schema.optional(Schema.Boolean), + conversation: Schema.optional(Schema.String), + max_tool_calls: Schema.optional(Schema.Number), + metadata: Schema.optional(JsonObject), + parallel_tool_calls: Schema.optional(Schema.Boolean), + previous_response_id: Schema.optional(Schema.String), prompt_cache_key: Schema.optional(Schema.String), - include: optionalArray(Schema.Literal("reasoning.encrypted_content")), + prompt_cache_retention: Schema.optional(Schema.String), + safety_identifier: Schema.optional(Schema.String), + service_tier: Schema.optional(Schema.String), + top_logprobs: Schema.optional(Schema.Number), + truncation: Schema.optional(Schema.String), + user: Schema.optional(Schema.String), + include: optionalArray(Schema.String), reasoning: Schema.optional( Schema.Struct({ effort: Schema.optional(OpenAIOptions.OpenAIReasoningEffort), - summary: Schema.optional(Schema.Literal("auto")), + summary: Schema.optional(Schema.String), }), ), text: Schema.optional( @@ -129,10 +141,15 @@ const OpenAIResponsesCoreFields = { top_p: Schema.optional(Schema.Number), } -const OpenAIResponsesBody = Schema.Struct({ - ...OpenAIResponsesCoreFields, - stream: Schema.Literal(true), -}) +// Unknown provider options are passed through verbatim with their top-level +// key snake-cased; the rest record validates them against any JSON value. +const OpenAIResponsesBody = Schema.StructWithRest( + Schema.Struct({ + ...OpenAIResponsesCoreFields, + stream: Schema.Literal(true), + }), + [Schema.Record(Schema.String, Schema.Any)], +) export type OpenAIResponsesBody = Schema.Schema.Type const OpenAIResponsesWebSocketMessage = Schema.StructWithRest( @@ -293,14 +310,15 @@ const lowerToolResultOutput = Effect.fn("OpenAIResponses.lowerToolResultOutput") // Text/json/error results are encoded as a plain string for backward // compatibility with existing cassettes and provider expectations. if (part.result.type !== "content") return ProviderShared.toolResultText(part) - return yield* Effect.forEach(part.result.value, lowerToolResultContentItem) + const items: ReadonlyArray = part.result.value + return yield* Effect.forEach(items, lowerToolResultContentItem) }) const lowerMessages = Effect.fn("OpenAIResponses.lowerMessages")(function* (request: LLMRequest) { const system: OpenAIResponsesInputItem[] = request.system.length === 0 ? [] : [{ role: "system", content: ProviderShared.joinText(request.system) }] const input: OpenAIResponsesInputItem[] = [...system] - const store = OpenAIOptions.store(request) + const store = OpenAIOptions.options(request).store for (const message of request.messages) { if (message.role === "user") { @@ -355,25 +373,47 @@ const lowerMessages = Effect.fn("OpenAIResponses.lowerMessages")(function* (requ return input }) -const lowerOptions = Effect.fn("OpenAIResponses.lowerOptions")(function* (request: LLMRequest) { - const store = OpenAIOptions.store(request) - const promptCacheKey = OpenAIOptions.promptCacheKey(request) - const effort = OpenAIOptions.reasoningEffort(request) - if (effort && !OpenAIOptions.isReasoningEffort(effort)) - return yield* invalid(`OpenAI Responses does not support reasoning effort ${effort}`) - const summary = OpenAIOptions.reasoningSummary(request) - const encryptedState = OpenAIOptions.encryptedReasoning(request) - const verbosity = OpenAIOptions.textVerbosity(request) - const instructions = OpenAIOptions.instructions(request) +const lowerOptions = (request: LLMRequest) => { + const options = OpenAIOptions.options(request) + // OpenAI Responses does not accept the `max` reasoning effort variant. + const effort = OpenAIOptions.isReasoningEffort(options.reasoningEffort) ? options.reasoningEffort : undefined + const summary = options.reasoningSummary + const verbosity = options.textVerbosity + // `logprobs` is enabled only by `true` or a numeric top-N. `false` and + // `undefined` leave the request without the logprobs include + top_logprobs. + const logprobsEnabled = options.logprobs === true || typeof options.logprobs === "number" + const include = (() => { + const base = options.include ? [...options.include] : [] + if (options.includeEncryptedReasoning && !base.includes("reasoning.encrypted_content")) { + base.push("reasoning.encrypted_content") + } + if (logprobsEnabled && !base.includes("message.output_text.logprobs")) { + base.push("message.output_text.logprobs") + } + return base.length > 0 ? base : undefined + })() + const topLogprobs = typeof options.logprobs === "number" ? options.logprobs : options.logprobs === true ? 20 : undefined return { - ...(instructions ? { instructions } : {}), - ...(store !== undefined ? { store } : {}), - ...(promptCacheKey ? { prompt_cache_key: promptCacheKey } : {}), - ...(encryptedState ? { include: ["reasoning.encrypted_content"] as const } : {}), - ...(effort || summary ? { reasoning: { effort, summary } } : {}), - ...(verbosity ? { text: { verbosity } } : {}), + ...ProviderOptions.passthrough(options, OpenAIOptions.KNOWN_KEYS), + ...(options.instructions !== undefined ? { instructions: options.instructions } : {}), + ...(options.store !== undefined ? { store: options.store } : {}), + ...(options.conversation !== undefined ? { conversation: options.conversation } : {}), + ...(options.maxToolCalls !== undefined ? { max_tool_calls: options.maxToolCalls } : {}), + ...(options.metadata !== undefined ? { metadata: options.metadata } : {}), + ...(options.parallelToolCalls !== undefined ? { parallel_tool_calls: options.parallelToolCalls } : {}), + ...(options.previousResponseId !== undefined ? { previous_response_id: options.previousResponseId } : {}), + ...(options.promptCacheKey !== undefined ? { prompt_cache_key: options.promptCacheKey } : {}), + ...(options.promptCacheRetention !== undefined ? { prompt_cache_retention: options.promptCacheRetention } : {}), + ...(options.safetyIdentifier !== undefined ? { safety_identifier: options.safetyIdentifier } : {}), + ...(options.serviceTier !== undefined ? { service_tier: options.serviceTier } : {}), + ...(topLogprobs !== undefined ? { top_logprobs: topLogprobs } : {}), + ...(options.truncation !== undefined ? { truncation: options.truncation } : {}), + ...(options.user !== undefined ? { user: options.user } : {}), + ...(include ? { include } : {}), + ...(effort !== undefined || summary !== undefined ? { reasoning: { effort, summary } } : {}), + ...(verbosity !== undefined ? { text: { verbosity } } : {}), } -}) +} const fromRequest = Effect.fn("OpenAIResponses.fromRequest")(function* (request: LLMRequest) { const generation = request.generation @@ -386,7 +426,7 @@ const fromRequest = Effect.fn("OpenAIResponses.fromRequest")(function* (request: max_output_tokens: generation?.maxTokens, temperature: generation?.temperature, top_p: generation?.topP, - ...(yield* lowerOptions(request)), + ...lowerOptions(request), } }) diff --git a/packages/llm/src/protocols/utils/openai-options.ts b/packages/llm/src/protocols/utils/openai-options.ts index 59e201421..04ef228fd 100644 --- a/packages/llm/src/protocols/utils/openai-options.ts +++ b/packages/llm/src/protocols/utils/openai-options.ts @@ -1,60 +1,73 @@ import { Schema } from "effect" -import type { LLMRequest, ReasoningEffort, TextVerbosity as TextVerbosityValue } from "../../schema" -import { ReasoningEfforts, TextVerbosity } from "../../schema" +import type { LLMRequest } from "../../schema" +import { ReasoningEfforts, TextVerbosity, type ReasoningEffort } from "../../schema" +import { ProviderOptions } from "./provider-options" export const OpenAIReasoningEfforts = ReasoningEfforts.filter( (effort): effort is Exclude => effort !== "max", ) export type OpenAIReasoningEffort = (typeof OpenAIReasoningEfforts)[number] -const REASONING_EFFORTS = new Set(ReasoningEfforts) const OPENAI_REASONING_EFFORTS = new Set(OpenAIReasoningEfforts) -const TEXT_VERBOSITY = new Set(["low", "medium", "high"]) export const OpenAIReasoningEffort = Schema.Literals(OpenAIReasoningEfforts) export const OpenAITextVerbosity = TextVerbosity -const isAnyReasoningEffort = (effort: unknown): effort is ReasoningEffort => - typeof effort === "string" && REASONING_EFFORTS.has(effort) - export const isReasoningEffort = (effort: unknown): effort is OpenAIReasoningEffort => typeof effort === "string" && OPENAI_REASONING_EFFORTS.has(effort) -const isTextVerbosity = (value: unknown): value is TextVerbosityValue => - typeof value === "string" && TEXT_VERBOSITY.has(value) - -const options = (request: LLMRequest) => request.providerOptions?.openai - -export const store = (request: LLMRequest): boolean | undefined => { - const value = options(request)?.store - return typeof value === "boolean" ? value : undefined +// Typed AI SDK OpenAI options. Mirrors the camelCase surface AI SDK accepts. +// Known keys are typed; everything else passes through to the wire body with +// its top-level key snake-cased. +export interface Options { + readonly store?: boolean + readonly promptCacheKey?: string + readonly promptCacheRetention?: string + readonly reasoningEffort?: ReasoningEffort + readonly reasoningSummary?: string + readonly textVerbosity?: "low" | "medium" | "high" + readonly include?: ReadonlyArray + readonly includeEncryptedReasoning?: boolean + readonly instructions?: string + readonly conversation?: string + readonly maxToolCalls?: number + readonly metadata?: Record + readonly parallelToolCalls?: boolean + readonly previousResponseId?: string + readonly safetyIdentifier?: string + readonly serviceTier?: string + readonly logprobs?: boolean | number + readonly truncation?: string + readonly user?: string + readonly [extra: string]: unknown } -export const reasoningEffort = (request: LLMRequest): ReasoningEffort | undefined => { - const value = options(request)?.reasoningEffort - return isAnyReasoningEffort(value) ? value : undefined -} +export const KNOWN_KEYS: ReadonlySet = new Set([ + "store", + "promptCacheKey", + "promptCacheRetention", + "reasoningEffort", + "reasoningSummary", + "textVerbosity", + "include", + "includeEncryptedReasoning", + "instructions", + "conversation", + "maxToolCalls", + "metadata", + "parallelToolCalls", + "previousResponseId", + "safetyIdentifier", + "serviceTier", + "logprobs", + "truncation", + "user", +]) -export const reasoningSummary = (request: LLMRequest): "auto" | undefined => { - return options(request)?.reasoningSummary === "auto" ? "auto" : undefined -} - -export const encryptedReasoning = (request: LLMRequest) => - options(request)?.includeEncryptedReasoning === true ? true : undefined - -export const promptCacheKey = (request: LLMRequest) => { - const value = options(request)?.promptCacheKey - return typeof value === "string" ? value : undefined -} - -export const textVerbosity = (request: LLMRequest) => { - const value = options(request)?.textVerbosity - return isTextVerbosity(value) ? value : undefined -} - -export const instructions = (request: LLMRequest) => { - const value = options(request)?.instructions - return typeof value === "string" ? value : undefined -} +// Read the merged `openai` provider option bag. Producers +// (`packages/opencode/src/provider/transform.ts`) emit typed values; we widen +// only the index signature so passthrough keys remain reachable. Invalid +// shapes surface in the lowerer where they're consumed, not at decode time. +export const options = (request: LLMRequest): Options => ProviderOptions.merge(request, ["openai"]) as Options export * as OpenAIOptions from "./openai-options" diff --git a/packages/llm/src/protocols/utils/provider-options.ts b/packages/llm/src/protocols/utils/provider-options.ts new file mode 100644 index 000000000..33aee2b82 --- /dev/null +++ b/packages/llm/src/protocols/utils/provider-options.ts @@ -0,0 +1,34 @@ +import type { LLMRequest } from "../../schema" + +const isRecord = (value: unknown): value is Record => + typeof value === "object" && value !== null && !Array.isArray(value) + +// Convert a single top-level option key from camelCase to snake_case. Values +// are left verbatim — recursive conversion would mangle structured payloads +// (IDs, nested provider-shaped objects) and provider APIs do not require it. +// PascalCase (`FooBar`) becomes `foo_bar` without a leading underscore. +export const snakeKey = (key: string) => + key.replace(/([a-z0-9])([A-Z])/g, "$1_$2").toLowerCase() + +// Merge provider option namespaces using AI SDK precedence semantics: later +// sources override earlier ones, missing namespaces are skipped. Used by every +// native protocol that reads request-level provider options. +export const merge = (request: LLMRequest, keys: ReadonlyArray) => { + const sources = keys.map((key) => request.providerOptions?.[key]).filter(isRecord) + return Object.assign({}, ...sources) as Record +} + +// Spread the unknown remainder of a merged option bag onto a provider body. +// `consumed` lists keys already lowered explicitly so they aren't duplicated +// or echoed at the wrong shape. +export const passthrough = (options: Record, consumed: ReadonlySet) => { + const result: Record = {} + for (const [key, value] of Object.entries(options)) { + if (consumed.has(key)) continue + if (value === undefined) continue + result[snakeKey(key)] = value + } + return result +} + +export * as ProviderOptions from "./provider-options" diff --git a/packages/llm/test/provider/anthropic-messages.test.ts b/packages/llm/test/provider/anthropic-messages.test.ts index 5198af9ab..2dac0fef2 100644 --- a/packages/llm/test/provider/anthropic-messages.test.ts +++ b/packages/llm/test/provider/anthropic-messages.test.ts @@ -209,6 +209,80 @@ describe("Anthropic Messages route", () => { }), ) + it.effect("lowers Anthropic thinking provider option (enabled)", () => + Effect.gen(function* () { + const prepared = yield* LLMClient.prepare( + LLM.request({ + model, + prompt: "think", + providerOptions: { anthropic: { thinking: { type: "enabled", budgetTokens: 12345 } } }, + }), + ) + expect(prepared.body).toMatchObject({ thinking: { type: "enabled", budget_tokens: 12345 } }) + }), + ) + + it.effect("lowers Anthropic adaptive thinking with effort sibling", () => + Effect.gen(function* () { + const prepared = yield* LLMClient.prepare( + LLM.request({ + model, + prompt: "think", + providerOptions: { anthropic: { thinking: { type: "adaptive", display: "summarized" }, effort: "max" } }, + }), + ) + expect(prepared.body).toMatchObject({ + thinking: { type: "adaptive", display: "summarized" }, + effort: "max", + }) + }), + ) + + it.effect("sets per-tool eager_input_streaming only when toolStreaming is true", () => + Effect.gen(function* () { + const off = yield* LLMClient.prepare( + LLM.request({ + model, + prompt: "use tool", + providerOptions: { anthropic: { toolStreaming: false } }, + tools: [{ name: "lookup", description: "lookup", inputSchema: { type: "object" } }], + }), + ) + expect(off.body.tools?.[0]?.eager_input_streaming).toBeUndefined() + + const on = yield* LLMClient.prepare( + LLM.request({ + model, + prompt: "use tool", + providerOptions: { anthropic: { toolStreaming: true } }, + tools: [{ name: "lookup", description: "lookup", inputSchema: { type: "object" } }], + }), + ) + expect(on.body.tools?.[0]?.eager_input_streaming).toBe(true) + }), + ) + + it.effect("passes unknown Anthropic provider options through with snake-cased keys", () => + Effect.gen(function* () { + const prepared = yield* LLMClient.prepare( + LLM.request({ + model, + prompt: "go", + providerOptions: { + anthropic: { + anthropicBeta: ["claude-2024-07-15"], + customField: { keepCamelCase: true }, + }, + }, + }), + ) + expect(prepared.body).toMatchObject({ + anthropic_beta: ["claude-2024-07-15"], + custom_field: { keepCamelCase: true }, + }) + }), + ) + it.effect("lowers preserved Anthropic reasoning signature metadata", () => Effect.gen(function* () { const prepared = yield* LLMClient.prepare( diff --git a/packages/llm/test/provider/openai-compatible-chat.test.ts b/packages/llm/test/provider/openai-compatible-chat.test.ts index 43ae283e9..e595072d7 100644 --- a/packages/llm/test/provider/openai-compatible-chat.test.ts +++ b/packages/llm/test/provider/openai-compatible-chat.test.ts @@ -6,7 +6,7 @@ import { Auth, LLMClient } from "../../src/route" import * as OpenAICompatible from "../../src/providers/openai-compatible" import * as OpenAICompatibleChat from "../../src/protocols/openai-compatible-chat" import { it } from "../lib/effect" -import { dynamicResponse } from "../lib/http" +import { dynamicResponse, fixedResponse } from "../lib/http" import { sseEvents } from "../lib/sse" const Json = Schema.fromJsonString(Schema.Unknown) @@ -199,6 +199,134 @@ describe("OpenAI-compatible Chat route", () => { }), ) + it.effect("passes through compatible options and prior reasoning for tool continuations", () => + Effect.gen(function* () { + const prepared = yield* LLMClient.prepare( + LLM.request({ + model, + providerOptions: { + deepseek: { + reasoningEffort: "max", + textVerbosity: "low", + promptCacheKey: "session_123", + strictJsonSchema: false, + enable_thinking: true, + }, + }, + messages: [ + Message.user("Audit the site"), + Message.make({ + role: "assistant", + native: { openaiCompatible: { reasoning_content: "I should inspect the page." } }, + content: [ToolCallPart.make({ id: "call_1", name: "lookup", input: { query: "page" } })], + }), + ], + }), + ) + + expect(prepared.body).toMatchObject({ + reasoning_effort: "max", + verbosity: "low", + prompt_cache_key: "session_123", + enable_thinking: true, + messages: [ + { role: "user", content: "Audit the site" }, + { + role: "assistant", + reasoning_content: "I should inspect the page.", + tool_calls: [ + { + id: "call_1", + function: { name: "lookup", arguments: '{"query":"page"}' }, + }, + ], + }, + ], + }) + expect(prepared.body).not.toHaveProperty("strictJsonSchema") + }), + ) + + it.effect("preserves structured reasoning_details on compatible continuations", () => + Effect.gen(function* () { + const details = [ + { type: "reasoning.text", text: "Let me work through this.", format: "anthropic-claude-v1", index: 0 }, + { type: "reasoning.encrypted", data: "sha256:abc123", format: "anthropic-claude-v1", index: 1 }, + ] + const prepared = yield* LLMClient.prepare( + LLM.request({ + model, + messages: [ + Message.make({ + role: "assistant", + native: { openaiCompatible: { reasoning_details: details } }, + content: [ToolCallPart.make({ id: "call_1", name: "lookup", input: {} })], + }), + ], + }), + ) + + expect(prepared.body).toMatchObject({ + messages: [{ role: "assistant", reasoning_details: details }], + }) + }), + ) + + it.effect("resolves dot-scoped compatible provider options", () => + Effect.gen(function* () { + const prepared = yield* LLMClient.prepare( + LLM.request({ + model: OpenAICompatibleChat.route + .with({ provider: "opencode.internal", endpoint: { baseURL: "https://api.example.test/v1" } }) + .model({ id: "reasoning-model" }), + prompt: "Think.", + providerOptions: { opencode: { reasoningEffort: "max", enable_thinking: true } }, + }), + ) + + expect(prepared.body).toMatchObject({ reasoning_effort: "max", enable_thinking: true }) + }), + ) + + it.effect("does not apply OpenAI effort limits to compatible providers", () => + Effect.gen(function* () { + const prepared = yield* LLMClient.prepare( + LLM.request({ + model: OpenAICompatibleChat.route + .with({ provider: "openai", endpoint: { baseURL: "https://compatible.example.test/v1" } }) + .model({ id: "reasoning-model" }), + prompt: "Think.", + providerOptions: { openai: { reasoningEffort: "max" } }, + }), + ) + + expect(prepared.body).toMatchObject({ reasoning_effort: "max" }) + }), + ) + + it.effect("parses compatible reasoning field variants", () => + Effect.gen(function* () { + const response = yield* LLMClient.generate(request).pipe( + Effect.provide( + fixedResponse( + sseEvents( + deltaChunk({ reasoning: "fallback" }), + deltaChunk({ + reasoning_details: [ + { type: "reasoning.text", text: " text-detail", format: "anthropic-claude-v1", index: 0 }, + { type: "reasoning.summary", summary: " summary-detail", format: "anthropic-claude-v1", index: 1 }, + ], + }), + deltaChunk({}, "stop"), + ), + ), + ), + ) + + expect(response.reasoning).toBe("fallback text-detail summary-detail") + }), + ) + it.effect("posts to the configured compatible endpoint and parses text usage", () => Effect.gen(function* () { const response = yield* LLMClient.generate(request).pipe( diff --git a/packages/llm/test/provider/openai-responses.test.ts b/packages/llm/test/provider/openai-responses.test.ts index 3f291728a..b5179a8da 100644 --- a/packages/llm/test/provider/openai-responses.test.ts +++ b/packages/llm/test/provider/openai-responses.test.ts @@ -407,6 +407,30 @@ describe("OpenAI Responses route", () => { }), ) + it.effect("passes unknown OpenAI provider options through with snake-cased keys", () => + Effect.gen(function* () { + const prepared = yield* LLMClient.prepare( + LLM.request({ + model: OpenAI.configure({ baseURL: "https://api.openai.test/v1/", apiKey: "test" }).model("gpt-5.2"), + prompt: "passthrough", + providerOptions: { + openai: { + customCamelCaseField: "value", + already_snake_case: 42, + nested: { keepCamelCase: true }, + }, + }, + }), + ) + + expect(prepared.body).toMatchObject({ + custom_camel_case_field: "value", + already_snake_case: 42, + nested: { keepCamelCase: true }, + }) + }), + ) + it.effect("request OpenAI provider options override route defaults", () => Effect.gen(function* () { const prepared = yield* LLMClient.prepare( diff --git a/packages/opencode/src/session/llm/native-request.ts b/packages/opencode/src/session/llm/native-request.ts index b7f30e24c..ed208a8dc 100644 --- a/packages/opencode/src/session/llm/native-request.ts +++ b/packages/opencode/src/session/llm/native-request.ts @@ -110,7 +110,9 @@ const messages = (input: readonly ModelMessage[]) => { Message.make({ role: message.role, content: content(message.content), - native: isRecord(message.providerOptions) ? { providerOptions: message.providerOptions } : undefined, + // Message provider options are already provider-native wire metadata + // (for example DeepSeek's reasoning_content continuation field). + native: isRecord(message.providerOptions) ? message.providerOptions : undefined, }), ] }) diff --git a/packages/opencode/test/fixtures/recordings/session/native-zen-tool-loop.json b/packages/opencode/test/fixtures/recordings/session/native-zen-tool-loop.json index d9bf9e450..a7369c89c 100644 --- a/packages/opencode/test/fixtures/recordings/session/native-zen-tool-loop.json +++ b/packages/opencode/test/fixtures/recordings/session/native-zen-tool-loop.json @@ -17,7 +17,7 @@ "headers": { "content-type": "application/json" }, - "body": "{\"model\":\"gpt-5.2-codex\",\"input\":[{\"role\":\"system\",\"content\":\"Answer using tools when appropriate.\\nUse the get_weather tool exactly once to look up Paris, then reply with exactly: Paris is sunny.\"},{\"role\":\"user\",\"content\":[{\"type\":\"input_text\",\"text\":\"What is the weather in Paris?\"}]}],\"tools\":[{\"type\":\"function\",\"name\":\"get_weather\",\"description\":\"Get the current weather for a city.\",\"parameters\":{\"$schema\":\"http://json-schema.org/draft-07/schema#\",\"type\":\"object\",\"properties\":{\"city\":{\"type\":\"string\"}},\"required\":[\"city\"],\"additionalProperties\":false}}],\"store\":false,\"prompt_cache_key\":\"session-recorded-opencode-loop\",\"reasoning\":{\"effort\":\"medium\",\"summary\":\"auto\"},\"max_output_tokens\":32000,\"stream\":true}" + "body": "{\"model\":\"gpt-5.2-codex\",\"input\":[{\"role\":\"system\",\"content\":\"Answer using tools when appropriate.\\nUse the get_weather tool exactly once to look up Paris, then reply with exactly: Paris is sunny.\"},{\"role\":\"user\",\"content\":[{\"type\":\"input_text\",\"text\":\"What is the weather in Paris?\"}]}],\"tools\":[{\"type\":\"function\",\"name\":\"get_weather\",\"description\":\"Get the current weather for a city.\",\"parameters\":{\"$schema\":\"http://json-schema.org/draft-07/schema#\",\"type\":\"object\",\"properties\":{\"city\":{\"type\":\"string\"}},\"required\":[\"city\"],\"additionalProperties\":false}}],\"store\":false,\"prompt_cache_key\":\"session-recorded-opencode-loop\",\"include\":[\"reasoning.encrypted_content\"],\"reasoning\":{\"effort\":\"medium\",\"summary\":\"auto\"},\"max_output_tokens\":32000,\"stream\":true}" }, "response": { "status": 200, @@ -35,7 +35,7 @@ "headers": { "content-type": "application/json" }, - "body": "{\"model\":\"gpt-5.2-codex\",\"input\":[{\"role\":\"system\",\"content\":\"Answer using tools when appropriate.\\nUse the get_weather tool exactly once to look up Paris, then reply with exactly: Paris is sunny.\"},{\"role\":\"user\",\"content\":[{\"type\":\"input_text\",\"text\":\"What is the weather in Paris?\"}]},{\"type\":\"function_call\",\"call_id\":\"call_DfI0RwTrlaizfnQ9zkJC8rks\",\"name\":\"get_weather\",\"arguments\":\"{\\\"city\\\":{}}\"},{\"type\":\"function_call_output\",\"call_id\":\"call_DfI0RwTrlaizfnQ9zkJC8rks\",\"output\":\"{\\\"temperature\\\":22,\\\"condition\\\":\\\"sunny\\\"}\"}],\"tools\":[{\"type\":\"function\",\"name\":\"get_weather\",\"description\":\"Get the current weather for a city.\",\"parameters\":{\"$schema\":\"http://json-schema.org/draft-07/schema#\",\"type\":\"object\",\"properties\":{\"city\":{\"type\":\"string\"}},\"required\":[\"city\"],\"additionalProperties\":false}}],\"store\":false,\"prompt_cache_key\":\"session-recorded-opencode-loop\",\"reasoning\":{\"effort\":\"medium\",\"summary\":\"auto\"},\"max_output_tokens\":32000,\"stream\":true}" + "body": "{\"model\":\"gpt-5.2-codex\",\"input\":[{\"role\":\"system\",\"content\":\"Answer using tools when appropriate.\\nUse the get_weather tool exactly once to look up Paris, then reply with exactly: Paris is sunny.\"},{\"role\":\"user\",\"content\":[{\"type\":\"input_text\",\"text\":\"What is the weather in Paris?\"}]},{\"type\":\"function_call\",\"call_id\":\"call_DfI0RwTrlaizfnQ9zkJC8rks\",\"name\":\"get_weather\",\"arguments\":\"{\\\"city\\\":{}}\"},{\"type\":\"function_call_output\",\"call_id\":\"call_DfI0RwTrlaizfnQ9zkJC8rks\",\"output\":\"{\\\"temperature\\\":22,\\\"condition\\\":\\\"sunny\\\"}\"}],\"tools\":[{\"type\":\"function\",\"name\":\"get_weather\",\"description\":\"Get the current weather for a city.\",\"parameters\":{\"$schema\":\"http://json-schema.org/draft-07/schema#\",\"type\":\"object\",\"properties\":{\"city\":{\"type\":\"string\"}},\"required\":[\"city\"],\"additionalProperties\":false}}],\"store\":false,\"prompt_cache_key\":\"session-recorded-opencode-loop\",\"include\":[\"reasoning.encrypted_content\"],\"reasoning\":{\"effort\":\"medium\",\"summary\":\"auto\"},\"max_output_tokens\":32000,\"stream\":true}" }, "response": { "status": 200, diff --git a/packages/opencode/test/session/llm-native.test.ts b/packages/opencode/test/session/llm-native.test.ts index ec5be5b05..c4ff837a7 100644 --- a/packages/opencode/test/session/llm-native.test.ts +++ b/packages/opencode/test/session/llm-native.test.ts @@ -6,6 +6,7 @@ import { Effect, Layer, Stream } from "effect" import { LLMNative } from "@/session/llm/native-request" import { LLMNativeRuntime } from "@/session/llm/native-runtime" import type { Provider } from "@/provider/provider" +import { ProviderTransform } from "@/provider/transform" import { ModelID, ProviderID } from "@/provider/schema" import { OAUTH_DUMMY_KEY } from "@/auth" import { testEffect } from "../lib/effect" @@ -70,6 +71,21 @@ const providerInfo: Provider.Info = { models: {}, } +const compatibleModel: Provider.Model = { + ...baseModel, + id: ModelID.make("deepseek-v4-flash-free"), + providerID: ProviderID.make("opencode"), + api: { + id: "deepseek-v4-flash-free", + url: "https://ai.example.test/v1", + npm: "@ai-sdk/openai-compatible", + }, + capabilities: { + ...baseModel.capabilities, + interleaved: { field: "reasoning_content" }, + }, +} + const it = testEffect( LLMClient.layer.pipe(Layer.provide(Layer.mergeAll(RequestExecutor.defaultLayer, WebSocketExecutor.layer))), ) @@ -326,6 +342,70 @@ describe("session.llm-native.request", () => { ]) }) + it.effect("preserves OpenAI-compatible reasoning continuation and provider options", () => + Effect.gen(function* () { + const messages = ProviderTransform.message( + [ + { role: "user", content: "Audit the site" }, + { + role: "assistant", + content: [ + { type: "reasoning", text: "I should inspect the page." }, + { + type: "tool-call", + toolCallId: "call-1", + toolName: "devtools_new_page", + input: { url: "https://example.test" }, + }, + ], + }, + ] as ModelMessage[], + compatibleModel, + {}, + ) + + const prepared = yield* LLMClient.prepare( + LLMNative.request({ + model: compatibleModel, + apiKey: "test-key", + messages, + providerOptions: ProviderTransform.providerOptions(compatibleModel, { + reasoningEffort: "max", + textVerbosity: "low", + promptCacheKey: "session-1", + enable_thinking: true, + }), + }), + ) + + expect(prepared.body).toMatchObject({ + model: "deepseek-v4-flash-free", + reasoning_effort: "max", + verbosity: "low", + prompt_cache_key: "session-1", + enable_thinking: true, + messages: [ + { role: "user", content: "Audit the site" }, + { + role: "assistant", + content: null, + reasoning_content: "I should inspect the page.", + tool_calls: [ + { + id: "call-1", + type: "function", + function: { + name: "devtools_new_page", + arguments: '{"url":"https://example.test"}', + }, + }, + ], + }, + ], + }) + }), + ) + test("selects native request routes for provider packages", () => { const openai = LLMNative.model({ model: { ...baseModel, api: { ...baseModel.api, url: "", npm: "@ai-sdk/openai" } },