Compare commits
1 commit
dev
...
fix-native
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6aaaac27d0 |
12 changed files with 630 additions and 107 deletions
|
|
@ -20,6 +20,7 @@ import {
|
||||||
import { JsonObject, optionalArray, optionalNull, ProviderShared } from "./shared"
|
import { JsonObject, optionalArray, optionalNull, ProviderShared } from "./shared"
|
||||||
import * as Cache from "./utils/cache"
|
import * as Cache from "./utils/cache"
|
||||||
import { Lifecycle } from "./utils/lifecycle"
|
import { Lifecycle } from "./utils/lifecycle"
|
||||||
|
import { ProviderOptions } from "./utils/provider-options"
|
||||||
import { ToolStream } from "./utils/tool-stream"
|
import { ToolStream } from "./utils/tool-stream"
|
||||||
|
|
||||||
const ADAPTER = "anthropic-messages"
|
const ADAPTER = "anthropic-messages"
|
||||||
|
|
@ -136,6 +137,7 @@ const AnthropicTool = Schema.Struct({
|
||||||
description: Schema.String,
|
description: Schema.String,
|
||||||
input_schema: JsonObject,
|
input_schema: JsonObject,
|
||||||
cache_control: Schema.optional(AnthropicCacheControl),
|
cache_control: Schema.optional(AnthropicCacheControl),
|
||||||
|
eager_input_streaming: Schema.optional(Schema.Boolean),
|
||||||
})
|
})
|
||||||
type AnthropicTool = Schema.Schema.Type<typeof AnthropicTool>
|
type AnthropicTool = Schema.Schema.Type<typeof AnthropicTool>
|
||||||
|
|
||||||
|
|
@ -144,10 +146,10 @@ const AnthropicToolChoice = Schema.Union([
|
||||||
Schema.Struct({ type: Schema.tag("tool"), name: Schema.String }),
|
Schema.Struct({ type: Schema.tag("tool"), name: Schema.String }),
|
||||||
])
|
])
|
||||||
|
|
||||||
const AnthropicThinking = Schema.Struct({
|
// Anthropic accepts several `thinking` shapes (enabled with `budget_tokens`,
|
||||||
type: Schema.tag("enabled"),
|
// adaptive with optional `display`, and disabled). The body schema permits the
|
||||||
budget_tokens: Schema.Number,
|
// full union so explicit lowering can pick the correct fields per model.
|
||||||
})
|
const AnthropicThinkingBody = Schema.Record(Schema.String, Schema.Unknown)
|
||||||
|
|
||||||
const AnthropicBodyFields = {
|
const AnthropicBodyFields = {
|
||||||
model: Schema.String,
|
model: Schema.String,
|
||||||
|
|
@ -161,9 +163,12 @@ const AnthropicBodyFields = {
|
||||||
top_p: Schema.optional(Schema.Number),
|
top_p: Schema.optional(Schema.Number),
|
||||||
top_k: Schema.optional(Schema.Number),
|
top_k: Schema.optional(Schema.Number),
|
||||||
stop_sequences: optionalArray(Schema.String),
|
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>
|
export type AnthropicMessagesBody = Schema.Schema.Type<typeof AnthropicMessagesBody>
|
||||||
|
|
||||||
const AnthropicUsage = Schema.Struct({
|
const AnthropicUsage = Schema.Struct({
|
||||||
|
|
@ -254,11 +259,16 @@ const signatureFromMetadata = (metadata: ProviderMetadata | undefined): string |
|
||||||
return typeof anthropic.signature === "string" ? anthropic.signature : undefined
|
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,
|
name: tool.name,
|
||||||
description: tool.description,
|
description: tool.description,
|
||||||
input_schema: tool.inputSchema,
|
input_schema: tool.inputSchema,
|
||||||
cache_control: cacheControl(breakpoints, tool.cache),
|
cache_control: cacheControl(breakpoints, tool.cache),
|
||||||
|
eager_input_streaming: eagerInputStreaming ? true : undefined,
|
||||||
})
|
})
|
||||||
|
|
||||||
const lowerToolChoice = (toolChoice: NonNullable<LLMRequest["toolChoice"]>) =>
|
const lowerToolChoice = (toolChoice: NonNullable<LLMRequest["toolChoice"]>) =>
|
||||||
|
|
@ -413,24 +423,46 @@ const lowerMessages = Effect.fn("AnthropicMessages.lowerMessages")(function* (
|
||||||
return messages
|
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) {
|
interface AnthropicOptions {
|
||||||
const thinking = anthropicOptions(request)?.thinking
|
readonly thinking?: AnthropicThinking
|
||||||
if (!ProviderShared.isRecord(thinking) || thinking.type !== "enabled") return undefined
|
readonly effort?: AnthropicEffort
|
||||||
const budget =
|
readonly toolStreaming?: boolean
|
||||||
typeof thinking.budgetTokens === "number"
|
readonly [extra: string]: unknown
|
||||||
? thinking.budgetTokens
|
}
|
||||||
: typeof thinking.budget_tokens === "number"
|
|
||||||
? thinking.budget_tokens
|
const ANTHROPIC_KNOWN_KEYS: ReadonlySet<string> = new Set(["thinking", "effort", "toolStreaming"])
|
||||||
: undefined
|
|
||||||
if (budget === undefined) return yield* invalid("Anthropic thinking provider option requires budgetTokens")
|
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 }
|
return { type: "enabled" as const, budget_tokens: budget }
|
||||||
})
|
}
|
||||||
|
|
||||||
const fromRequest = Effect.fn("AnthropicMessages.fromRequest")(function* (request: LLMRequest) {
|
const fromRequest = Effect.fn("AnthropicMessages.fromRequest")(function* (request: LLMRequest) {
|
||||||
const toolChoice = request.toolChoice ? yield* lowerToolChoice(request.toolChoice) : undefined
|
const toolChoice = request.toolChoice ? yield* lowerToolChoice(request.toolChoice) : undefined
|
||||||
const generation = request.generation
|
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 →
|
// Allocate the 4-breakpoint budget in invalidation order: tools → system →
|
||||||
// messages. Tools live highest in the cache hierarchy, so when callers
|
// 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.
|
// 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 =
|
const tools =
|
||||||
request.tools.length === 0 || request.toolChoice?.type === "none"
|
request.tools.length === 0 || request.toolChoice?.type === "none"
|
||||||
? undefined
|
? undefined
|
||||||
: request.tools.map((tool) => lowerTool(breakpoints, tool))
|
: request.tools.map((tool) => lowerTool(breakpoints, tool, eagerInputStreaming))
|
||||||
const system =
|
const system =
|
||||||
request.system.length === 0
|
request.system.length === 0
|
||||||
? undefined
|
? undefined
|
||||||
|
|
@ -454,6 +486,7 @@ const fromRequest = Effect.fn("AnthropicMessages.fromRequest")(function* (reques
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
|
...ProviderOptions.passthrough(options, ANTHROPIC_KNOWN_KEYS),
|
||||||
model: request.model.id,
|
model: request.model.id,
|
||||||
system,
|
system,
|
||||||
messages,
|
messages,
|
||||||
|
|
@ -465,7 +498,8 @@ const fromRequest = Effect.fn("AnthropicMessages.fromRequest")(function* (reques
|
||||||
top_p: generation?.topP,
|
top_p: generation?.topP,
|
||||||
top_k: generation?.topK,
|
top_k: generation?.topK,
|
||||||
stop_sequences: generation?.stop,
|
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"
|
} from "../schema"
|
||||||
import { isRecord, JsonObject, optionalArray, optionalNull, ProviderShared } from "./shared"
|
import { isRecord, JsonObject, optionalArray, optionalNull, ProviderShared } from "./shared"
|
||||||
import { OpenAIOptions } from "./utils/openai-options"
|
import { OpenAIOptions } from "./utils/openai-options"
|
||||||
|
import { ProviderOptions } from "./utils/provider-options"
|
||||||
import { Lifecycle } from "./utils/lifecycle"
|
import { Lifecycle } from "./utils/lifecycle"
|
||||||
import { ToolStream } from "./utils/tool-stream"
|
import { ToolStream } from "./utils/tool-stream"
|
||||||
|
|
||||||
|
|
@ -50,6 +51,10 @@ const OpenAIChatAssistantToolCall = Schema.Struct({
|
||||||
})
|
})
|
||||||
type OpenAIChatAssistantToolCall = Schema.Schema.Type<typeof OpenAIChatAssistantToolCall>
|
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([
|
const OpenAIChatMessage = Schema.Union([
|
||||||
Schema.Struct({ role: Schema.Literal("system"), content: Schema.String }),
|
Schema.Struct({ role: Schema.Literal("system"), content: Schema.String }),
|
||||||
Schema.Struct({ role: Schema.Literal("user"), 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),
|
content: Schema.NullOr(Schema.String),
|
||||||
tool_calls: optionalArray(OpenAIChatAssistantToolCall),
|
tool_calls: optionalArray(OpenAIChatAssistantToolCall),
|
||||||
reasoning_content: Schema.optional(Schema.String),
|
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 }),
|
Schema.Struct({ role: Schema.Literal("tool"), tool_call_id: Schema.String, content: Schema.String }),
|
||||||
]).pipe(Schema.toTaggedUnion("role"))
|
]).pipe(Schema.toTaggedUnion("role"))
|
||||||
|
|
@ -79,7 +85,7 @@ export const bodyFields = {
|
||||||
stream: Schema.Literal(true),
|
stream: Schema.Literal(true),
|
||||||
stream_options: Schema.optional(Schema.Struct({ include_usage: Schema.Boolean })),
|
stream_options: Schema.optional(Schema.Struct({ include_usage: Schema.Boolean })),
|
||||||
store: Schema.optional(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),
|
max_tokens: Schema.optional(Schema.Number),
|
||||||
temperature: Schema.optional(Schema.Number),
|
temperature: Schema.optional(Schema.Number),
|
||||||
top_p: Schema.optional(Schema.Number),
|
top_p: Schema.optional(Schema.Number),
|
||||||
|
|
@ -88,7 +94,7 @@ export const bodyFields = {
|
||||||
seed: Schema.optional(Schema.Number),
|
seed: Schema.optional(Schema.Number),
|
||||||
stop: optionalArray(Schema.String),
|
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>
|
export type OpenAIChatBody = Schema.Schema.Type<typeof OpenAIChatBody>
|
||||||
|
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
|
|
@ -125,9 +131,16 @@ const OpenAIChatToolCallDelta = Schema.Struct({
|
||||||
})
|
})
|
||||||
type OpenAIChatToolCallDelta = Schema.Schema.Type<typeof OpenAIChatToolCallDelta>
|
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({
|
const OpenAIChatDelta = Schema.Struct({
|
||||||
content: optionalNull(Schema.String),
|
content: optionalNull(Schema.String),
|
||||||
reasoning_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)),
|
tool_calls: optionalNull(Schema.Array(OpenAIChatToolCallDelta)),
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
@ -188,6 +201,16 @@ const lowerToolCall = (part: ToolCallPart): OpenAIChatAssistantToolCall => ({
|
||||||
const openAICompatibleReasoningContent = (native: unknown) =>
|
const openAICompatibleReasoningContent = (native: unknown) =>
|
||||||
isRecord(native) && typeof native.reasoning_content === "string" ? native.reasoning_content : undefined
|
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 lowerUserMessage = Effect.fn("OpenAIChat.lowerUserMessage")(function* (message: OpenAIChatRequestMessage) {
|
||||||
const content: TextPart[] = []
|
const content: TextPart[] = []
|
||||||
for (const part of message.content) {
|
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),
|
content: content.length === 0 ? null : ProviderShared.joinText(content),
|
||||||
tool_calls: toolCalls.length === 0 ? undefined : toolCalls,
|
tool_calls: toolCalls.length === 0 ? undefined : toolCalls,
|
||||||
reasoning_content: openAICompatibleReasoningContent(message.native?.openaiCompatible),
|
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 lowerOptions = Effect.fn("OpenAIChat.lowerOptions")(function* (request: LLMRequest) {
|
||||||
const store = OpenAIOptions.store(request)
|
const options = OpenAIOptions.options(request)
|
||||||
const reasoningEffort = OpenAIOptions.reasoningEffort(request)
|
const effort = options.reasoningEffort
|
||||||
if (reasoningEffort && !OpenAIOptions.isReasoningEffort(reasoningEffort))
|
if (effort !== undefined && !OpenAIOptions.isReasoningEffort(effort))
|
||||||
return yield* invalid(`OpenAI Chat does not support reasoning effort ${reasoningEffort}`)
|
return yield* invalid(`OpenAI Chat does not support reasoning effort ${effort}`)
|
||||||
return {
|
return {
|
||||||
...(store !== undefined ? { store } : {}),
|
...ProviderOptions.passthrough(options, OpenAIOptions.KNOWN_KEYS),
|
||||||
...(reasoningEffort ? { reasoning_effort: reasoningEffort } : {}),
|
...(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
|
let lifecycle = state.lifecycle
|
||||||
|
|
||||||
if (delta?.reasoning_content)
|
// OpenRouter-style `reasoning_details` ships an array of typed reasoning
|
||||||
lifecycle = Lifecycle.reasoningDelta(lifecycle, events, "reasoning-0", delta.reasoning_content)
|
// 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)
|
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 { Route, type RouteRoutedModelInput } from "../route/client"
|
||||||
import { Endpoint } from "../route/endpoint"
|
import { Endpoint } from "../route/endpoint"
|
||||||
import { Framing } from "../route/framing"
|
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"
|
import * as OpenAIChat from "./openai-chat"
|
||||||
|
|
||||||
const ADAPTER = "openai-compatible-chat"
|
const ADAPTER = "openai-compatible-chat"
|
||||||
|
|
||||||
export type OpenAICompatibleChatModelInput = RouteRoutedModelInput
|
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
|
* Route for non-OpenAI providers that expose an OpenAI Chat-compatible
|
||||||
* `/chat/completions` endpoint. Reuses `OpenAIChat.protocol` end-to-end and
|
* `/chat/completions` endpoint. Reuses OpenAI Chat streaming behavior while
|
||||||
* overrides only the route id so providers can be resolved per-family without
|
* allowing compatible providers to pass through additional request-body
|
||||||
* colliding with native OpenAI. Provider helpers configure the route endpoint
|
* options such as `enable_thinking` and extended reasoning efforts.
|
||||||
* before model selection.
|
|
||||||
*/
|
*/
|
||||||
export const route = Route.make({
|
export const route = Route.make({
|
||||||
id: ADAPTER,
|
id: ADAPTER,
|
||||||
protocol: OpenAIChat.protocol,
|
protocol,
|
||||||
endpoint: Endpoint.path("/chat/completions"),
|
endpoint: Endpoint.path("/chat/completions"),
|
||||||
framing: Framing.sse,
|
framing: Framing.sse,
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -19,6 +19,7 @@ import {
|
||||||
} from "../schema"
|
} from "../schema"
|
||||||
import { JsonObject, optionalArray, optionalNull, ProviderShared } from "./shared"
|
import { JsonObject, optionalArray, optionalNull, ProviderShared } from "./shared"
|
||||||
import { OpenAIOptions } from "./utils/openai-options"
|
import { OpenAIOptions } from "./utils/openai-options"
|
||||||
|
import { ProviderOptions } from "./utils/provider-options"
|
||||||
import { Lifecycle } from "./utils/lifecycle"
|
import { Lifecycle } from "./utils/lifecycle"
|
||||||
import { ToolStream } from "./utils/tool-stream"
|
import { ToolStream } from "./utils/tool-stream"
|
||||||
|
|
||||||
|
|
@ -111,12 +112,23 @@ const OpenAIResponsesCoreFields = {
|
||||||
tools: optionalArray(OpenAIResponsesTool),
|
tools: optionalArray(OpenAIResponsesTool),
|
||||||
tool_choice: Schema.optional(OpenAIResponsesToolChoice),
|
tool_choice: Schema.optional(OpenAIResponsesToolChoice),
|
||||||
store: Schema.optional(Schema.Boolean),
|
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),
|
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(
|
reasoning: Schema.optional(
|
||||||
Schema.Struct({
|
Schema.Struct({
|
||||||
effort: Schema.optional(OpenAIOptions.OpenAIReasoningEffort),
|
effort: Schema.optional(OpenAIOptions.OpenAIReasoningEffort),
|
||||||
summary: Schema.optional(Schema.Literal("auto")),
|
summary: Schema.optional(Schema.String),
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
text: Schema.optional(
|
text: Schema.optional(
|
||||||
|
|
@ -129,10 +141,15 @@ const OpenAIResponsesCoreFields = {
|
||||||
top_p: Schema.optional(Schema.Number),
|
top_p: Schema.optional(Schema.Number),
|
||||||
}
|
}
|
||||||
|
|
||||||
const OpenAIResponsesBody = Schema.Struct({
|
// Unknown provider options are passed through verbatim with their top-level
|
||||||
...OpenAIResponsesCoreFields,
|
// key snake-cased; the rest record validates them against any JSON value.
|
||||||
stream: Schema.Literal(true),
|
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>
|
export type OpenAIResponsesBody = Schema.Schema.Type<typeof OpenAIResponsesBody>
|
||||||
|
|
||||||
const OpenAIResponsesWebSocketMessage = Schema.StructWithRest(
|
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
|
// Text/json/error results are encoded as a plain string for backward
|
||||||
// compatibility with existing cassettes and provider expectations.
|
// compatibility with existing cassettes and provider expectations.
|
||||||
if (part.result.type !== "content") return ProviderShared.toolResultText(part)
|
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 lowerMessages = Effect.fn("OpenAIResponses.lowerMessages")(function* (request: LLMRequest) {
|
||||||
const system: OpenAIResponsesInputItem[] =
|
const system: OpenAIResponsesInputItem[] =
|
||||||
request.system.length === 0 ? [] : [{ role: "system", content: ProviderShared.joinText(request.system) }]
|
request.system.length === 0 ? [] : [{ role: "system", content: ProviderShared.joinText(request.system) }]
|
||||||
const input: OpenAIResponsesInputItem[] = [...system]
|
const input: OpenAIResponsesInputItem[] = [...system]
|
||||||
const store = OpenAIOptions.store(request)
|
const store = OpenAIOptions.options(request).store
|
||||||
|
|
||||||
for (const message of request.messages) {
|
for (const message of request.messages) {
|
||||||
if (message.role === "user") {
|
if (message.role === "user") {
|
||||||
|
|
@ -355,25 +373,47 @@ const lowerMessages = Effect.fn("OpenAIResponses.lowerMessages")(function* (requ
|
||||||
return input
|
return input
|
||||||
})
|
})
|
||||||
|
|
||||||
const lowerOptions = Effect.fn("OpenAIResponses.lowerOptions")(function* (request: LLMRequest) {
|
const lowerOptions = (request: LLMRequest) => {
|
||||||
const store = OpenAIOptions.store(request)
|
const options = OpenAIOptions.options(request)
|
||||||
const promptCacheKey = OpenAIOptions.promptCacheKey(request)
|
// OpenAI Responses does not accept the `max` reasoning effort variant.
|
||||||
const effort = OpenAIOptions.reasoningEffort(request)
|
const effort = OpenAIOptions.isReasoningEffort(options.reasoningEffort) ? options.reasoningEffort : undefined
|
||||||
if (effort && !OpenAIOptions.isReasoningEffort(effort))
|
const summary = options.reasoningSummary
|
||||||
return yield* invalid(`OpenAI Responses does not support reasoning effort ${effort}`)
|
const verbosity = options.textVerbosity
|
||||||
const summary = OpenAIOptions.reasoningSummary(request)
|
// `logprobs` is enabled only by `true` or a numeric top-N. `false` and
|
||||||
const encryptedState = OpenAIOptions.encryptedReasoning(request)
|
// `undefined` leave the request without the logprobs include + top_logprobs.
|
||||||
const verbosity = OpenAIOptions.textVerbosity(request)
|
const logprobsEnabled = options.logprobs === true || typeof options.logprobs === "number"
|
||||||
const instructions = OpenAIOptions.instructions(request)
|
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 {
|
return {
|
||||||
...(instructions ? { instructions } : {}),
|
...ProviderOptions.passthrough(options, OpenAIOptions.KNOWN_KEYS),
|
||||||
...(store !== undefined ? { store } : {}),
|
...(options.instructions !== undefined ? { instructions: options.instructions } : {}),
|
||||||
...(promptCacheKey ? { prompt_cache_key: promptCacheKey } : {}),
|
...(options.store !== undefined ? { store: options.store } : {}),
|
||||||
...(encryptedState ? { include: ["reasoning.encrypted_content"] as const } : {}),
|
...(options.conversation !== undefined ? { conversation: options.conversation } : {}),
|
||||||
...(effort || summary ? { reasoning: { effort, summary } } : {}),
|
...(options.maxToolCalls !== undefined ? { max_tool_calls: options.maxToolCalls } : {}),
|
||||||
...(verbosity ? { text: { verbosity } } : {}),
|
...(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 fromRequest = Effect.fn("OpenAIResponses.fromRequest")(function* (request: LLMRequest) {
|
||||||
const generation = request.generation
|
const generation = request.generation
|
||||||
|
|
@ -386,7 +426,7 @@ const fromRequest = Effect.fn("OpenAIResponses.fromRequest")(function* (request:
|
||||||
max_output_tokens: generation?.maxTokens,
|
max_output_tokens: generation?.maxTokens,
|
||||||
temperature: generation?.temperature,
|
temperature: generation?.temperature,
|
||||||
top_p: generation?.topP,
|
top_p: generation?.topP,
|
||||||
...(yield* lowerOptions(request)),
|
...lowerOptions(request),
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,60 +1,73 @@
|
||||||
import { Schema } from "effect"
|
import { Schema } from "effect"
|
||||||
import type { LLMRequest, ReasoningEffort, TextVerbosity as TextVerbosityValue } from "../../schema"
|
import type { LLMRequest } from "../../schema"
|
||||||
import { ReasoningEfforts, TextVerbosity } from "../../schema"
|
import { ReasoningEfforts, TextVerbosity, type ReasoningEffort } from "../../schema"
|
||||||
|
import { ProviderOptions } from "./provider-options"
|
||||||
|
|
||||||
export const OpenAIReasoningEfforts = ReasoningEfforts.filter(
|
export const OpenAIReasoningEfforts = ReasoningEfforts.filter(
|
||||||
(effort): effort is Exclude<ReasoningEffort, "max"> => effort !== "max",
|
(effort): effort is Exclude<ReasoningEffort, "max"> => effort !== "max",
|
||||||
)
|
)
|
||||||
export type OpenAIReasoningEffort = (typeof OpenAIReasoningEfforts)[number]
|
export type OpenAIReasoningEffort = (typeof OpenAIReasoningEfforts)[number]
|
||||||
|
|
||||||
const REASONING_EFFORTS = new Set<string>(ReasoningEfforts)
|
|
||||||
const OPENAI_REASONING_EFFORTS = new Set<string>(OpenAIReasoningEfforts)
|
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 OpenAIReasoningEffort = Schema.Literals(OpenAIReasoningEfforts)
|
||||||
export const OpenAITextVerbosity = TextVerbosity
|
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 =>
|
export const isReasoningEffort = (effort: unknown): effort is OpenAIReasoningEffort =>
|
||||||
typeof effort === "string" && OPENAI_REASONING_EFFORTS.has(effort)
|
typeof effort === "string" && OPENAI_REASONING_EFFORTS.has(effort)
|
||||||
|
|
||||||
const isTextVerbosity = (value: unknown): value is TextVerbosityValue =>
|
// Typed AI SDK OpenAI options. Mirrors the camelCase surface AI SDK accepts.
|
||||||
typeof value === "string" && TEXT_VERBOSITY.has(value)
|
// Known keys are typed; everything else passes through to the wire body with
|
||||||
|
// its top-level key snake-cased.
|
||||||
const options = (request: LLMRequest) => request.providerOptions?.openai
|
export interface Options {
|
||||||
|
readonly store?: boolean
|
||||||
export const store = (request: LLMRequest): boolean | undefined => {
|
readonly promptCacheKey?: string
|
||||||
const value = options(request)?.store
|
readonly promptCacheRetention?: string
|
||||||
return typeof value === "boolean" ? value : undefined
|
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 => {
|
export const KNOWN_KEYS: ReadonlySet<string> = new Set([
|
||||||
const value = options(request)?.reasoningEffort
|
"store",
|
||||||
return isAnyReasoningEffort(value) ? value : undefined
|
"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 => {
|
// Read the merged `openai` provider option bag. Producers
|
||||||
return options(request)?.reasoningSummary === "auto" ? "auto" : undefined
|
// (`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 encryptedReasoning = (request: LLMRequest) =>
|
export const options = (request: LLMRequest): Options => ProviderOptions.merge(request, ["openai"]) as Options
|
||||||
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
|
|
||||||
}
|
|
||||||
|
|
||||||
export * as OpenAIOptions from "./openai-options"
|
export * as OpenAIOptions from "./openai-options"
|
||||||
|
|
|
||||||
34
packages/llm/src/protocols/utils/provider-options.ts
Normal file
34
packages/llm/src/protocols/utils/provider-options.ts
Normal file
|
|
@ -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", () =>
|
it.effect("lowers preserved Anthropic reasoning signature metadata", () =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const prepared = yield* LLMClient.prepare(
|
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 OpenAICompatible from "../../src/providers/openai-compatible"
|
||||||
import * as OpenAICompatibleChat from "../../src/protocols/openai-compatible-chat"
|
import * as OpenAICompatibleChat from "../../src/protocols/openai-compatible-chat"
|
||||||
import { it } from "../lib/effect"
|
import { it } from "../lib/effect"
|
||||||
import { dynamicResponse } from "../lib/http"
|
import { dynamicResponse, fixedResponse } from "../lib/http"
|
||||||
import { sseEvents } from "../lib/sse"
|
import { sseEvents } from "../lib/sse"
|
||||||
|
|
||||||
const Json = Schema.fromJsonString(Schema.Unknown)
|
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", () =>
|
it.effect("posts to the configured compatible endpoint and parses text usage", () =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const response = yield* LLMClient.generate(request).pipe(
|
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", () =>
|
it.effect("request OpenAI provider options override route defaults", () =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const prepared = yield* LLMClient.prepare<OpenAIResponses.OpenAIResponsesBody>(
|
const prepared = yield* LLMClient.prepare<OpenAIResponses.OpenAIResponsesBody>(
|
||||||
|
|
|
||||||
|
|
@ -110,7 +110,9 @@ const messages = (input: readonly ModelMessage[]) => {
|
||||||
Message.make({
|
Message.make({
|
||||||
role: message.role,
|
role: message.role,
|
||||||
content: content(message.content),
|
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,
|
||||||
}),
|
}),
|
||||||
]
|
]
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -17,7 +17,7 @@
|
||||||
"headers": {
|
"headers": {
|
||||||
"content-type": "application/json"
|
"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": {
|
"response": {
|
||||||
"status": 200,
|
"status": 200,
|
||||||
|
|
@ -35,7 +35,7 @@
|
||||||
"headers": {
|
"headers": {
|
||||||
"content-type": "application/json"
|
"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": {
|
"response": {
|
||||||
"status": 200,
|
"status": 200,
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@ import { Effect, Layer, Stream } from "effect"
|
||||||
import { LLMNative } from "@/session/llm/native-request"
|
import { LLMNative } from "@/session/llm/native-request"
|
||||||
import { LLMNativeRuntime } from "@/session/llm/native-runtime"
|
import { LLMNativeRuntime } from "@/session/llm/native-runtime"
|
||||||
import type { Provider } from "@/provider/provider"
|
import type { Provider } from "@/provider/provider"
|
||||||
|
import { ProviderTransform } from "@/provider/transform"
|
||||||
import { ModelID, ProviderID } from "@/provider/schema"
|
import { ModelID, ProviderID } from "@/provider/schema"
|
||||||
import { OAUTH_DUMMY_KEY } from "@/auth"
|
import { OAUTH_DUMMY_KEY } from "@/auth"
|
||||||
import { testEffect } from "../lib/effect"
|
import { testEffect } from "../lib/effect"
|
||||||
|
|
@ -70,6 +71,21 @@ const providerInfo: Provider.Info = {
|
||||||
models: {},
|
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(
|
const it = testEffect(
|
||||||
LLMClient.layer.pipe(Layer.provide(Layer.mergeAll(RequestExecutor.defaultLayer, WebSocketExecutor.layer))),
|
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", () => {
|
test("selects native request routes for provider packages", () => {
|
||||||
const openai = LLMNative.model({
|
const openai = LLMNative.model({
|
||||||
model: { ...baseModel, api: { ...baseModel.api, url: "", npm: "@ai-sdk/openai" } },
|
model: { ...baseModel, api: { ...baseModel.api, url: "", npm: "@ai-sdk/openai" } },
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue