fix(llm): preserve native provider options
This commit is contained in:
@@ -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<typeof AnthropicTool>
|
||||
|
||||
@@ -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<typeof AnthropicMessagesBody>
|
||||
|
||||
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<LLMRequest["toolChoice"]>) =>
|
||||
@@ -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<string> = 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 } : {}),
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@@ -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<typeof OpenAIChatAssistantToolCall>
|
||||
|
||||
// `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<typeof OpenAIChatBody>
|
||||
|
||||
// =============================================================================
|
||||
@@ -125,9 +131,16 @@ const OpenAIChatToolCallDelta = Schema.Struct({
|
||||
})
|
||||
type OpenAIChatToolCallDelta = Schema.Schema.Type<typeof OpenAIChatToolCallDelta>
|
||||
|
||||
// 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<Record<string, unknown>>
|
||||
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)
|
||||
|
||||
|
||||
@@ -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<typeof OpenAICompatibleChatBody>
|
||||
|
||||
// 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<string> = 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,
|
||||
})
|
||||
|
||||
@@ -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<typeof OpenAIResponsesBody>
|
||||
|
||||
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<ToolResultContentPart> = 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),
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@@ -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<ReasoningEffort, "max"> => effort !== "max",
|
||||
)
|
||||
export type OpenAIReasoningEffort = (typeof OpenAIReasoningEfforts)[number]
|
||||
|
||||
const REASONING_EFFORTS = new Set<string>(ReasoningEfforts)
|
||||
const OPENAI_REASONING_EFFORTS = new Set<string>(OpenAIReasoningEfforts)
|
||||
const TEXT_VERBOSITY = new Set<string>(["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<string>
|
||||
readonly includeEncryptedReasoning?: boolean
|
||||
readonly instructions?: string
|
||||
readonly conversation?: string
|
||||
readonly maxToolCalls?: number
|
||||
readonly metadata?: Record<string, unknown>
|
||||
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<string> = 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"
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
import type { LLMRequest } from "../../schema"
|
||||
|
||||
const isRecord = (value: unknown): value is Record<string, unknown> =>
|
||||
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<string>) => {
|
||||
const sources = keys.map((key) => request.providerOptions?.[key]).filter(isRecord)
|
||||
return Object.assign({}, ...sources) as Record<string, unknown>
|
||||
}
|
||||
|
||||
// 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<string, unknown>, consumed: ReadonlySet<string>) => {
|
||||
const result: Record<string, unknown> = {}
|
||||
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"
|
||||
@@ -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<AnthropicMessages.AnthropicMessagesBody>(
|
||||
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<AnthropicMessages.AnthropicMessagesBody>(
|
||||
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(
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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<OpenAIResponses.OpenAIResponsesBody>(
|
||||
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<OpenAIResponses.OpenAIResponsesBody>(
|
||||
|
||||
@@ -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,
|
||||
}),
|
||||
]
|
||||
})
|
||||
|
||||
+2
-2
@@ -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,
|
||||
|
||||
@@ -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" } },
|
||||
|
||||
Reference in New Issue
Block a user