| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254125512561257125812591260126112621263126412651266126712681269127012711272127312741275127612771278127912801281128212831284128512861287128812891290129112921293129412951296129712981299130013011302130313041305130613071308130913101311131213131314131513161317131813191320132113221323132413251326132713281329133013311332133313341335133613371338133913401341134213431344134513461347134813491350135113521353135413551356135713581359136013611362136313641365136613671368136913701371137213731374137513761377137813791380138113821383138413851386138713881389139013911392139313941395139613971398139914001401140214031404140514061407 |
- import type { ModelMessage, ToolResultPart } from "ai"
- import { mergeDeep, unique } from "remeda"
- import type { JSONSchema7 } from "@ai-sdk/provider"
- import type * as Provider from "./provider"
- import type * as ModelsDev from "@opencode-ai/core/models-dev"
- import { iife } from "@/util/iife"
- type Modality = NonNullable<ModelsDev.Model["modalities"]>["input"][number]
- function mimeToModality(mime: string): Modality | undefined {
- if (mime.startsWith("image/")) return "image"
- if (mime.startsWith("audio/")) return "audio"
- if (mime.startsWith("video/")) return "video"
- if (mime === "application/pdf") return "pdf"
- return undefined
- }
- export const OUTPUT_TOKEN_MAX = 32_000
- // OpenAI Responses `include` value that returns the encrypted reasoning state
- // needed for stateless multi-turn reasoning (store: false). Hoisted so every
- // branch that requests it stays in lockstep.
- const INCLUDE_ENCRYPTED_REASONING = ["reasoning.encrypted_content"] as const
- export function sanitizeSurrogates(content: string) {
- return content.replace(/[\uD800-\uDBFF](?![\uDC00-\uDFFF])|(?<![\uD800-\uDBFF])[\uDC00-\uDFFF]/g, "\uFFFD")
- }
- // Maps npm package to the key the AI SDK expects for providerOptions
- function sdkKey(npm: string): string | undefined {
- switch (npm) {
- case "@ai-sdk/github-copilot":
- return "copilot"
- case "@ai-sdk/azure":
- return "azure"
- case "@ai-sdk/openai":
- return "openai"
- case "@ai-sdk/amazon-bedrock":
- return "bedrock"
- case "@ai-sdk/anthropic":
- case "@ai-sdk/google-vertex/anthropic":
- return "anthropic"
- case "@ai-sdk/google-vertex":
- return "vertex"
- case "@ai-sdk/google":
- return "google"
- case "@ai-sdk/gateway":
- return "gateway"
- case "@openrouter/ai-sdk-provider":
- return "openrouter"
- case "ai-gateway-provider":
- // ai-gateway-provider/unified wraps createOpenAICompatible({ name: "Unified" }),
- // and @ai-sdk/openai-compatible parses compatibleOptions from one of
- // "openai-compatible" / "openaiCompatible" / "Unified" / "unified". The
- // "openai-compatible" key emits a deprecation warning at runtime, so we
- // pick the camelCase form the SDK now treats as canonical.
- return "openaiCompatible"
- }
- return undefined
- }
- // TODO: fix this stupid inefficient dogshit function
- function normalizeMessages(
- msgs: ModelMessage[],
- model: Provider.Model,
- _options: Record<string, unknown>,
- ): ModelMessage[] {
- const sanitizeToolResultOutput = (content: ToolResultPart) => {
- if (content.output.type === "text" || content.output.type === "error-text") {
- content.output.value = sanitizeSurrogates(content.output.value)
- }
- if (content.output.type === "content") {
- content.output.value = content.output.value.map((item) => {
- if (item.type === "text") {
- item.text = sanitizeSurrogates(item.text)
- }
- return item
- })
- }
- return content
- }
- msgs = msgs.map((msg) => {
- switch (msg.role) {
- case "tool":
- if (!Array.isArray(msg.content)) return msg
- msg.content = msg.content.map((content) => {
- if (content.type === "tool-result") {
- return sanitizeToolResultOutput(content)
- }
- return content
- })
- return msg
- case "system":
- msg.content = sanitizeSurrogates(msg.content)
- return msg
- case "user":
- if (typeof msg.content === "string") {
- msg.content = sanitizeSurrogates(msg.content)
- } else {
- msg.content = msg.content.map((content) => {
- if (content.type === "text") {
- content.text = sanitizeSurrogates(content.text)
- }
- return content
- })
- }
- return msg
- case "assistant":
- if (typeof msg.content === "string") {
- msg.content = sanitizeSurrogates(msg.content)
- } else {
- msg.content = msg.content.map((content) => {
- if (content.type === "text" || content.type === "reasoning") {
- content.text = sanitizeSurrogates(content.text)
- }
- if (content.type === "tool-result") {
- return sanitizeToolResultOutput(content)
- }
- return content
- })
- }
- return msg
- }
- })
- // Anthropic rejects messages with empty content - filter out empty string messages
- // and remove empty text/reasoning parts from array content
- if (model.api.npm === "@ai-sdk/anthropic") {
- msgs = msgs
- .map((msg) => {
- if (typeof msg.content === "string") {
- if (msg.content === "") return undefined
- return msg
- }
- if (!Array.isArray(msg.content)) return msg
- const filtered = msg.content.filter((part) => {
- if (part.type === "text") {
- return part.text !== ""
- }
- if (part.type === "reasoning") {
- return (
- part.text.trim().length > 0 ||
- part.providerOptions?.anthropic?.signature != null ||
- part.providerOptions?.anthropic?.redactedData != null
- )
- }
- return true
- })
- if (filtered.length === 0) return undefined
- return { ...msg, content: filtered }
- })
- .filter((msg): msg is ModelMessage => msg !== undefined && msg.content !== "")
- }
- // Bedrock specific transforms
- if (model.api.npm === "@ai-sdk/amazon-bedrock") {
- msgs = msgs
- .map((msg) => {
- if (typeof msg.content === "string") {
- if (msg.content === "") return undefined
- return msg
- }
- if (!Array.isArray(msg.content)) return msg
- const filtered = msg.content.filter((part) => {
- if (part.type === "text") {
- return part.text !== ""
- }
- if (part.type === "reasoning") {
- return (
- part.text.trim().length > 0 ||
- part.providerOptions?.bedrock?.signature != null ||
- part.providerOptions?.bedrock?.redactedData != null
- )
- }
- return true
- })
- if (filtered.length === 0) return undefined
- return { ...msg, content: filtered }
- })
- .filter((msg): msg is ModelMessage => msg !== undefined && msg.content !== "")
- }
- if (model.api.id.includes("claude")) {
- const scrub = (id: string) => id.replace(/[^a-zA-Z0-9_-]/g, "_")
- msgs = msgs.map((msg) => {
- if (msg.role === "assistant" && Array.isArray(msg.content)) {
- return {
- ...msg,
- content: msg.content.map((part) => {
- if (part.type === "tool-call" || part.type === "tool-result") {
- return { ...part, toolCallId: scrub(part.toolCallId) }
- }
- return part
- }),
- }
- }
- if (msg.role === "tool" && Array.isArray(msg.content)) {
- return {
- ...msg,
- content: msg.content.map((part) => {
- if (part.type === "tool-result") {
- return { ...part, toolCallId: scrub(part.toolCallId) }
- }
- return part
- }),
- }
- }
- return msg
- })
- }
- if (["@ai-sdk/anthropic", "@ai-sdk/google-vertex/anthropic"].includes(model.api.npm)) {
- // Anthropic rejects assistant turns where tool_use blocks are followed by non-tool
- // content, e.g. [tool_use, tool_use, text], with:
- // `tool_use` ids were found without `tool_result` blocks immediately after...
- //
- // Reorder that invalid shape into [text] + [tool_use, tool_use]. Consecutive
- // assistant messages are later merged by the provider/SDK, so preserving the
- // original [tool_use...] then [text] order still produces the invalid payload.
- //
- // The root cause appears to be somewhere upstream where the stream is originally
- // processed. We were unable to locate an exact narrower reproduction elsewhere,
- // so we keep this transform in place for the time being.
- msgs = msgs.flatMap((msg) => {
- if (msg.role !== "assistant" || !Array.isArray(msg.content)) return [msg]
- const parts = msg.content
- const first = parts.findIndex((part) => part.type === "tool-call")
- if (first === -1) return [msg]
- if (!parts.slice(first).some((part) => part.type !== "tool-call")) return [msg]
- return [
- { ...msg, content: parts.filter((part) => part.type !== "tool-call") },
- { ...msg, content: parts.filter((part) => part.type === "tool-call") },
- ]
- })
- }
- if (
- model.providerID === "mistral" ||
- model.api.id.toLowerCase().includes("mistral") ||
- model.api.id.toLocaleLowerCase().includes("devstral")
- ) {
- const scrub = (id: string) => {
- return id
- .replace(/[^a-zA-Z0-9]/g, "") // Remove non-alphanumeric characters
- .substring(0, 9) // Take first 9 characters
- .padEnd(9, "0") // Pad with zeros if less than 9 characters
- }
- const result: ModelMessage[] = []
- for (let i = 0; i < msgs.length; i++) {
- const msg = msgs[i]
- const nextMsg = msgs[i + 1]
- if (msg.role === "assistant" && Array.isArray(msg.content)) {
- msg.content = msg.content.map((part) => {
- if (part.type === "tool-call" || part.type === "tool-result") {
- return { ...part, toolCallId: scrub(part.toolCallId) }
- }
- return part
- })
- }
- if (msg.role === "tool" && Array.isArray(msg.content)) {
- msg.content = msg.content.map((part) => {
- if (part.type === "tool-result") {
- return { ...part, toolCallId: scrub(part.toolCallId) }
- }
- return part
- })
- }
- result.push(msg)
- // Fix message sequence: tool messages cannot be followed by user messages
- if (msg.role === "tool" && nextMsg?.role === "user") {
- result.push({
- role: "assistant",
- content: [
- {
- type: "text",
- text: "Done.",
- },
- ],
- })
- }
- }
- return result
- }
- // Deepseek requires all assistant messages to have reasoning on them
- if (model.api.id.toLowerCase().includes("deepseek")) {
- msgs = msgs.map((msg) => {
- if (msg.role !== "assistant") return msg
- if (Array.isArray(msg.content)) {
- if (msg.content.some((part) => part.type === "reasoning")) return msg
- return { ...msg, content: [...msg.content, { type: "reasoning", text: "" }] }
- }
- return {
- ...msg,
- content: [
- ...(msg.content ? [{ type: "text" as const, text: msg.content }] : []),
- { type: "reasoning" as const, text: "" },
- ],
- }
- })
- }
- if (
- typeof model.capabilities.interleaved === "object" &&
- model.capabilities.interleaved.field &&
- model.api.npm !== "@openrouter/ai-sdk-provider"
- ) {
- const field = model.capabilities.interleaved.field
- return msgs.map((msg) => {
- if (msg.role === "assistant" && Array.isArray(msg.content)) {
- const reasoningParts = msg.content.filter((part: any) => part.type === "reasoning")
- const reasoningText = reasoningParts.map((part: any) => part.text).join("")
- // Filter out reasoning parts from content
- const filteredContent = msg.content.filter((part: any) => part.type !== "reasoning")
- // Include reasoning_content | reasoning_details directly on the message for all assistant messages.
- // Always set the field even when empty — some providers (e.g. DeepSeek) may return empty
- // reasoning_content which still needs to be sent back in subsequent requests.
- return {
- ...msg,
- content: filteredContent,
- providerOptions: {
- ...msg.providerOptions,
- openaiCompatible: {
- ...msg.providerOptions?.openaiCompatible,
- [field]: reasoningText,
- },
- },
- }
- }
- return msg
- })
- }
- return msgs
- }
- function applyCaching(msgs: ModelMessage[], model: Provider.Model): ModelMessage[] {
- const system = msgs.filter((msg) => msg.role === "system").slice(0, 2)
- const final = msgs.filter((msg) => msg.role !== "system").slice(-2)
- const providerOptions = {
- anthropic: {
- cacheControl: { type: "ephemeral" },
- },
- openrouter: {
- cacheControl: { type: "ephemeral" },
- },
- bedrock: {
- cachePoint: { type: "default" },
- },
- openaiCompatible: {
- cache_control: { type: "ephemeral" },
- },
- copilot: {
- copilot_cache_control: { type: "ephemeral" },
- },
- alibaba: {
- cacheControl: { type: "ephemeral" },
- },
- }
- for (const msg of unique([...system, ...final])) {
- const useMessageLevelOptions =
- model.providerID === "anthropic" ||
- model.providerID.includes("bedrock") ||
- model.api.npm === "@ai-sdk/amazon-bedrock"
- const shouldUseContentOptions = !useMessageLevelOptions && Array.isArray(msg.content) && msg.content.length > 0
- if (shouldUseContentOptions) {
- const lastContent = msg.content[msg.content.length - 1]
- if (
- lastContent &&
- typeof lastContent === "object" &&
- lastContent.type !== "tool-approval-request" &&
- lastContent.type !== "tool-approval-response"
- ) {
- lastContent.providerOptions = mergeDeep(lastContent.providerOptions ?? {}, providerOptions)
- continue
- }
- }
- msg.providerOptions = mergeDeep(msg.providerOptions ?? {}, providerOptions)
- }
- return msgs
- }
- function unsupportedParts(msgs: ModelMessage[], model: Provider.Model): ModelMessage[] {
- return msgs.map((msg) => {
- if (msg.role !== "user" || !Array.isArray(msg.content)) return msg
- const filtered = msg.content.map((part) => {
- if (part.type !== "file" && part.type !== "image") return part
- // Check for empty base64 image data
- if (part.type === "image") {
- const imageStr = String(part.image)
- if (imageStr.startsWith("data:")) {
- const match = imageStr.match(/^data:([^;]+);base64,(.*)$/)
- if (match && (!match[2] || match[2].length === 0)) {
- return {
- type: "text" as const,
- text: "ERROR: Image file is empty or corrupted. Please provide a valid image.",
- }
- }
- }
- }
- const mime = part.type === "image" ? String(part.image).split(";")[0].replace("data:", "") : part.mediaType
- const filename = part.type === "file" ? part.filename : undefined
- const modality = mimeToModality(mime)
- if (!modality) return part
- if (model.capabilities.input[modality]) return part
- const name = filename ? `"${filename}"` : modality
- return {
- type: "text" as const,
- text: `ERROR: Cannot read ${name} (this model does not support ${modality} input). Inform the user.`,
- }
- })
- return { ...msg, content: filtered }
- })
- }
- export function message(msgs: ModelMessage[], model: Provider.Model, options: Record<string, unknown>) {
- msgs = unsupportedParts(msgs, model)
- msgs = normalizeMessages(msgs, model, options)
- if (
- (model.providerID === "anthropic" ||
- model.providerID === "google-vertex-anthropic" ||
- model.api.id.includes("anthropic") ||
- model.api.id.includes("claude") ||
- model.id.includes("anthropic") ||
- model.id.includes("claude") ||
- model.api.npm === "@ai-sdk/anthropic" ||
- model.api.npm === "@ai-sdk/alibaba") &&
- model.api.npm !== "@ai-sdk/gateway"
- ) {
- msgs = applyCaching(msgs, model)
- }
- // Remap providerOptions keys from stored providerID to expected SDK key
- const key = sdkKey(model.api.npm)
- if (key && key !== model.providerID) {
- const remap = (opts: Record<string, any> | undefined) => {
- if (!opts) return opts
- if (!(model.providerID in opts)) return opts
- const result = { ...opts }
- result[key] = result[model.providerID]
- delete result[model.providerID]
- return result
- }
- msgs = msgs.map((msg) => {
- if (!Array.isArray(msg.content)) return { ...msg, providerOptions: remap(msg.providerOptions) }
- return {
- ...msg,
- providerOptions: remap(msg.providerOptions),
- content: msg.content.map((part) => {
- if (part.type === "tool-approval-request" || part.type === "tool-approval-response") {
- return { ...part }
- }
- return { ...part, providerOptions: remap(part.providerOptions) }
- }),
- } as typeof msg
- })
- }
- return msgs
- }
- export function temperature(model: Provider.Model) {
- const id = model.id.toLowerCase()
- if (id.includes("qwen")) return 0.55
- if (id.includes("claude")) return undefined
- if (id.includes("gemini")) return 1.0
- if (id.includes("glm-4.6")) return 1.0
- if (id.includes("glm-4.7")) return 1.0
- if (id.includes("minimax-m2")) return 1.0
- if (id.includes("kimi-k2")) {
- // kimi-k2-thinking & kimi-k2.5 && kimi-k2p5 && kimi-k2-5
- if (["thinking", "k2.", "k2p", "k2-5"].some((s) => id.includes(s))) {
- return 1.0
- }
- return 0.6
- }
- return undefined
- }
- export function topP(model: Provider.Model) {
- const id = model.id.toLowerCase()
- if (id.includes("qwen")) return 1
- if (["minimax-m2", "gemini", "kimi-k2.5", "kimi-k2p5", "kimi-k2-5"].some((s) => id.includes(s))) {
- return 0.95
- }
- return undefined
- }
- export function topK(model: Provider.Model) {
- const id = model.id.toLowerCase()
- if (id.includes("minimax-m2")) {
- if (["m2.", "m25", "m21"].some((s) => id.includes(s))) return 40
- return 20
- }
- if (id.includes("gemini")) return 64
- return undefined
- }
- const WIDELY_SUPPORTED_EFFORTS = ["low", "medium", "high"]
- const OPENAI_EFFORTS = ["none", "minimal", ...WIDELY_SUPPORTED_EFFORTS, "xhigh"]
- const OPENAI_GPT5_1_EFFORTS = ["none", ...WIDELY_SUPPORTED_EFFORTS]
- const OPENAI_GPT5_2_PLUS_EFFORTS = [...OPENAI_GPT5_1_EFFORTS, "xhigh"]
- const OPENAI_GPT5_PRO_EFFORTS = ["high"]
- const OPENAI_GPT5_PRO_2_PLUS_EFFORTS = ["medium", "high", "xhigh"]
- const OPENAI_GPT5_CHAT_EFFORTS = ["medium"]
- const OPENAI_GPT5_CODEX_XHIGH_EFFORTS = [...WIDELY_SUPPORTED_EFFORTS, "xhigh"]
- const OPENAI_GPT5_CODEX_3_PLUS_EFFORTS = ["none", ...OPENAI_GPT5_CODEX_XHIGH_EFFORTS]
- // OpenAI rolled out the `none` reasoning_effort tier on this date (Responses API).
- // Models released before it 400 on `reasoning_effort: "none"`, so we only expose
- // it as a variant for models new enough to accept it.
- const OPENAI_NONE_EFFORT_RELEASE_DATE = "2025-11-13"
- // OpenAI rolled out the `xhigh` reasoning_effort tier on this date. Same reasoning.
- const OPENAI_XHIGH_EFFORT_RELEASE_DATE = "2025-12-04"
- // Matches members of the gpt-5 family across the id formats we encounter:
- // "gpt-5", "gpt-5-nano", "gpt-5.4", "openai/gpt-5.4-codex".
- // Anchored to start-of-string or "/" so it doesn't false-match "gpt-50" or "gpt-5o".
- const GPT5_FAMILY_RE = /(?:^|\/)gpt-5(?:[.-]|$)/
- const GPT5_VERSION_RE = /(?:^|\/)gpt-5[.-](\d+)(?:[.-]|$)/
- const GPT5_PRO_RE = /(?:^|\/)gpt-5[.-]?pro(?:[.-]|$)/
- const GPT5_VERSIONED_PRO_RE = /(?:^|\/)gpt-5[.-]\d+[.-]pro(?:[.-]|$)/
- function gpt5Version(apiId: string) {
- return Number(GPT5_VERSION_RE.exec(apiId)?.[1]) || undefined
- }
- function versionedGpt5ReasoningEfforts(apiId: string) {
- if (GPT5_VERSIONED_PRO_RE.test(apiId)) return OPENAI_GPT5_PRO_2_PLUS_EFFORTS
- const version = gpt5Version(apiId)
- if (version === undefined) return undefined
- if (version === 1) return OPENAI_GPT5_1_EFFORTS
- return OPENAI_GPT5_2_PLUS_EFFORTS
- }
- function gpt5CodexReasoningEfforts(apiId: string) {
- if (!GPT5_FAMILY_RE.test(apiId) || !apiId.includes("codex")) return undefined
- const version = gpt5Version(apiId)
- if (version !== undefined && version >= 3) return OPENAI_GPT5_CODEX_3_PLUS_EFFORTS
- if (apiId.includes("codex-max") || (version !== undefined && version >= 2)) return OPENAI_GPT5_CODEX_XHIGH_EFFORTS
- return WIDELY_SUPPORTED_EFFORTS
- }
- function gpt5ChatReasoningEfforts(apiId: string) {
- if (!GPT5_FAMILY_RE.test(apiId) || !apiId.includes("-chat")) return undefined
- return gpt5Version(apiId) === undefined ? [] : OPENAI_GPT5_CHAT_EFFORTS
- }
- // Computes the reasoning_effort tiers an OpenAI (or OpenAI-compatible upstream
- // routed through it, e.g. cf-ai-gateway) model exposes. Effort order: weakest
- // to strongest.
- function openaiReasoningEfforts(apiId: string, releaseDate: string) {
- const id = apiId.toLowerCase()
- if (id.includes("deep-research")) return ["medium"]
- const chatEfforts = gpt5ChatReasoningEfforts(id)
- if (chatEfforts) return chatEfforts
- if (GPT5_PRO_RE.test(id)) return OPENAI_GPT5_PRO_EFFORTS
- const codexEfforts = gpt5CodexReasoningEfforts(id)
- if (codexEfforts) return codexEfforts
- const versionedEfforts = versionedGpt5ReasoningEfforts(id)
- // GPT-5.1 replaced GPT-5's `minimal` effort with `none`; GPT-5.2+
- // additionally accepts `xhigh`. Model pages list the supported subset.
- if (versionedEfforts) return versionedEfforts
- const efforts = [...WIDELY_SUPPORTED_EFFORTS]
- if (GPT5_FAMILY_RE.test(id)) efforts.unshift("minimal")
- if (releaseDate >= OPENAI_NONE_EFFORT_RELEASE_DATE) efforts.unshift("none")
- if (releaseDate >= OPENAI_XHIGH_EFFORT_RELEASE_DATE) efforts.push("xhigh")
- return efforts
- }
- function openaiCompatibleReasoningEfforts(id: string) {
- const apiId = id.toLowerCase()
- const chatEfforts = gpt5ChatReasoningEfforts(apiId)
- if (chatEfforts) return chatEfforts
- if (GPT5_PRO_RE.test(apiId)) return OPENAI_GPT5_PRO_EFFORTS
- return gpt5CodexReasoningEfforts(apiId) ?? versionedGpt5ReasoningEfforts(apiId) ?? OPENAI_EFFORTS
- }
- function anthropicOpus47OrLater(apiId: string) {
- // Matches "opus-4.7" (Anthropic/Bedrock/Vertex) and "claude-4.7-opus" (SAP AI Core inverted).
- // Greedy \d+ correctly extends to multi-digit majors (e.g. "claude-10.0-opus") for forward compatibility.
- const version = /opus-(\d+)[.-](\d+)(?:[.@-]|$)|claude-(\d+)[.-](\d+)-opus(?:[.@-]|$)/i.exec(apiId)
- if (!version) return false
- const major = Number(version[1] ?? version[3])
- const minor = Number(version[2] ?? version[4])
- return major > 4 || (major === 4 && minor >= 7)
- }
- function anthropicAdaptiveEfforts(apiId: string): string[] | null {
- if (anthropicOpus47OrLater(apiId)) {
- return ["low", "medium", "high", "xhigh", "max"]
- }
- if (
- [
- "opus-4-6",
- "opus-4.6",
- "4-6-opus",
- "4.6-opus",
- "sonnet-4-6",
- "sonnet-4.6",
- "4-6-sonnet",
- "4.6-sonnet",
- ].some((v) => apiId.includes(v))
- ) {
- return ["low", "medium", "high", "max"]
- }
- return null
- }
- function googleThinkingLevelEfforts(apiId: string) {
- const id = apiId.toLowerCase()
- if (!id.includes("gemini-3")) return ["low", "high"]
- if (id.includes("flash-image")) return ["minimal", "high"]
- if (id.includes("pro-image")) return ["high"]
- if (id.includes("flash")) return ["minimal", "low", "medium", "high"]
- return ["low", "medium", "high"]
- }
- function googleThinkingBudgetMax(apiId: string) {
- const id = apiId.toLowerCase()
- if (id.includes("2.5") && id.includes("pro") && !id.includes("flash")) return 32_768
- return 24_576
- }
- export function variants(model: Provider.Model): Record<string, Record<string, any>> {
- if (!model.capabilities.reasoning) return {}
- const id = model.id.toLowerCase()
- const adaptiveOpus = anthropicOpus47OrLater(model.api.id)
- const adaptiveEfforts = anthropicAdaptiveEfforts(model.api.id)
- if (
- id.includes("deepseek-chat") ||
- id.includes("deepseek-reasoner") ||
- id.includes("deepseek-r1") ||
- id.includes("deepseek-v3") ||
- id.includes("minimax") ||
- id.includes("glm") ||
- id.includes("kimi") ||
- id.includes("k2p") ||
- id.includes("qwen") ||
- id.includes("big-pickle")
- )
- return {}
- // see: https://docs.x.ai/docs/guides/reasoning#control-how-hard-the-model-thinks
- if (id.includes("grok") && id.includes("grok-3-mini")) {
- if (model.api.npm === "@openrouter/ai-sdk-provider") {
- return {
- low: { reasoning: { effort: "low" } },
- high: { reasoning: { effort: "high" } },
- }
- }
- return {
- low: { reasoningEffort: "low" },
- high: { reasoningEffort: "high" },
- }
- }
- if (id.includes("grok")) return {}
- switch (model.api.npm) {
- case "@openrouter/ai-sdk-provider":
- if (!id.includes("gpt") && !id.includes("gemini-3") && !id.includes("claude")) return {}
- return Object.fromEntries(
- (id.includes("gpt") ? openaiCompatibleReasoningEfforts(id) : OPENAI_EFFORTS).map((effort) => [
- effort,
- { reasoning: { effort } },
- ]),
- )
- case "ai-gateway-provider": {
- // Cloudflare AI Gateway routes every upstream through its OpenAI-compatible
- // /v1/compat endpoint, so the body is always OAI-shaped. The gateway
- // translates `reasoning_effort` to the upstream provider's native control
- // (e.g. Anthropic thinking budgets) when needed. Variants therefore stay
- // OAI-style for all upstreams, with an extended effort set for OpenAI
- // models that support it.
- if (model.api.id.startsWith("openai/")) {
- const efforts = openaiReasoningEfforts(model.api.id, model.release_date)
- return Object.fromEntries(efforts.map((effort) => [effort, { reasoningEffort: effort }]))
- }
- return Object.fromEntries(WIDELY_SUPPORTED_EFFORTS.map((effort) => [effort, { reasoningEffort: effort }]))
- }
- case "@ai-sdk/gateway":
- if (model.id.includes("anthropic")) {
- if (adaptiveEfforts) {
- return Object.fromEntries(
- adaptiveEfforts.map((effort) => [
- effort,
- {
- thinking: {
- type: "adaptive",
- // Opus 4.7+ flips the API default for `display` to "omitted", which
- // returns empty thinking blocks. Force "summarized" so summaries
- // survive (4.6/Sonnet 4.6 already default to "summarized").
- ...(adaptiveOpus ? { display: "summarized" } : {}),
- },
- effort,
- },
- ]),
- )
- }
- return {
- high: {
- thinking: {
- type: "enabled",
- budgetTokens: 16000,
- },
- },
- max: {
- thinking: {
- type: "enabled",
- budgetTokens: 31999,
- },
- },
- }
- }
- if (model.id.includes("google")) {
- if (id.includes("2.5")) {
- return {
- high: {
- thinkingConfig: {
- includeThoughts: true,
- thinkingBudget: 16000,
- },
- },
- max: {
- thinkingConfig: {
- includeThoughts: true,
- thinkingBudget: 24576,
- },
- },
- }
- }
- return Object.fromEntries(
- ["low", "high"].map((effort) => [
- effort,
- {
- includeThoughts: true,
- thinkingLevel: effort,
- },
- ]),
- )
- }
- return Object.fromEntries(
- openaiCompatibleReasoningEfforts(model.api.id).map((effort) => [effort, { reasoningEffort: effort }]),
- )
- case "@ai-sdk/github-copilot":
- if (model.id.includes("gemini")) {
- // currently github copilot only returns thinking
- return {}
- }
- if (model.id.includes("claude")) {
- return Object.fromEntries(WIDELY_SUPPORTED_EFFORTS.map((effort) => [effort, { reasoningEffort: effort }]))
- }
- const copilotEfforts = iife(() => {
- if (id.includes("5.1-codex-max") || id.includes("5.2") || id.includes("5.3"))
- return [...WIDELY_SUPPORTED_EFFORTS, "xhigh"]
- const arr = [...WIDELY_SUPPORTED_EFFORTS]
- if (id.includes("gpt-5") && model.release_date >= "2025-12-04") arr.push("xhigh")
- return arr
- })
- return Object.fromEntries(
- copilotEfforts.map((effort) => [
- effort,
- {
- reasoningEffort: effort,
- reasoningSummary: "auto",
- include: INCLUDE_ENCRYPTED_REASONING,
- },
- ]),
- )
- case "@ai-sdk/cerebras":
- // https://v5.ai-sdk.dev/providers/ai-sdk-providers/cerebras
- case "@ai-sdk/togetherai":
- // https://v5.ai-sdk.dev/providers/ai-sdk-providers/togetherai
- case "@ai-sdk/xai":
- // https://v5.ai-sdk.dev/providers/ai-sdk-providers/xai
- case "@ai-sdk/deepinfra":
- // https://v5.ai-sdk.dev/providers/ai-sdk-providers/deepinfra
- case "venice-ai-sdk-provider":
- // https://docs.venice.ai/overview/guides/reasoning-models#reasoning-effort
- case "@ai-sdk/openai-compatible":
- const efforts = [...WIDELY_SUPPORTED_EFFORTS]
- if (model.api.id.toLowerCase().includes("deepseek-v4")) {
- efforts.push("max")
- }
- return Object.fromEntries(efforts.map((effort) => [effort, { reasoningEffort: effort }]))
- case "@ai-sdk/azure":
- // https://v5.ai-sdk.dev/providers/ai-sdk-providers/azure
- if (id === "o1-mini") return {}
- return Object.fromEntries(
- (GPT5_FAMILY_RE.test(id) && gpt5Version(id) === undefined
- ? ["minimal", ...WIDELY_SUPPORTED_EFFORTS]
- : WIDELY_SUPPORTED_EFFORTS
- ).map((effort) => [
- effort,
- {
- reasoningEffort: effort,
- reasoningSummary: "auto",
- include: INCLUDE_ENCRYPTED_REASONING,
- },
- ]),
- )
- case "@ai-sdk/openai": {
- // https://v5.ai-sdk.dev/providers/ai-sdk-providers/openai
- const efforts = openaiReasoningEfforts(model.api.id, model.release_date)
- return Object.fromEntries(
- efforts.map((effort) => [
- effort,
- {
- reasoningEffort: effort,
- reasoningSummary: "auto",
- include: INCLUDE_ENCRYPTED_REASONING,
- },
- ]),
- )
- }
- case "@ai-sdk/anthropic":
- // https://v5.ai-sdk.dev/providers/ai-sdk-providers/anthropic
- case "@ai-sdk/google-vertex/anthropic":
- // https://v5.ai-sdk.dev/providers/ai-sdk-providers/google-vertex#anthropic-provider
- if (adaptiveEfforts) {
- let efforts = [...adaptiveEfforts]
- if (model.providerID === "github-copilot") {
- if (model.api.id.includes("opus-4.7")) {
- efforts = ["medium"]
- }
- // Efforts currently supported are: low, medium, high
- efforts = efforts.filter((v) => v !== "max" && v !== "xhigh")
- }
- return Object.fromEntries(
- efforts.map((effort) => [
- effort,
- {
- thinking: {
- type: "adaptive",
- ...(adaptiveOpus ? { display: "summarized" } : {}),
- },
- effort,
- },
- ]),
- )
- }
- if (["opus-4-5", "opus-4.5"].some((v) => model.api.id.includes(v))) {
- return Object.fromEntries(WIDELY_SUPPORTED_EFFORTS.map((effort) => [effort, { effort }]))
- }
- return {
- high: {
- thinking: {
- type: "enabled",
- budgetTokens: Math.min(16_000, Math.floor(model.limit.output / 2 - 1)),
- },
- },
- max: {
- thinking: {
- type: "enabled",
- budgetTokens: Math.min(31_999, model.limit.output - 1),
- },
- },
- }
- case "@ai-sdk/amazon-bedrock":
- // https://v5.ai-sdk.dev/providers/ai-sdk-providers/amazon-bedrock
- if (adaptiveEfforts) {
- return Object.fromEntries(
- adaptiveEfforts.map((effort) => [
- effort,
- {
- reasoningConfig: {
- type: "adaptive",
- maxReasoningEffort: effort,
- ...(adaptiveOpus ? { display: "summarized" } : {}),
- },
- },
- ]),
- )
- }
- // For Anthropic models on Bedrock, use reasoningConfig with budgetTokens
- if (model.api.id.includes("anthropic")) {
- return {
- high: {
- reasoningConfig: {
- type: "enabled",
- budgetTokens: 16000,
- },
- },
- max: {
- reasoningConfig: {
- type: "enabled",
- budgetTokens: 31999,
- },
- },
- }
- }
- // For Amazon Nova models, use reasoningConfig with maxReasoningEffort
- return Object.fromEntries(
- WIDELY_SUPPORTED_EFFORTS.map((effort) => [
- effort,
- {
- reasoningConfig: {
- type: "enabled",
- maxReasoningEffort: effort,
- },
- },
- ]),
- )
- case "@ai-sdk/google-vertex":
- // https://v5.ai-sdk.dev/providers/ai-sdk-providers/google-vertex
- case "@ai-sdk/google":
- // https://v5.ai-sdk.dev/providers/ai-sdk-providers/google-generative-ai
- if (id.includes("2.5")) {
- return {
- high: {
- thinkingConfig: {
- includeThoughts: true,
- thinkingBudget: 16000,
- },
- },
- max: {
- thinkingConfig: {
- includeThoughts: true,
- thinkingBudget: googleThinkingBudgetMax(id),
- },
- },
- }
- }
- return Object.fromEntries(
- googleThinkingLevelEfforts(id).map((effort) => [
- effort,
- {
- thinkingConfig: {
- includeThoughts: true,
- thinkingLevel: effort,
- },
- },
- ]),
- )
- case "@ai-sdk/mistral":
- // https://v5.ai-sdk.dev/providers/ai-sdk-providers/mistral
- // https://docs.mistral.ai/capabilities/reasoning/adjustable
- if (!model.capabilities.reasoning) return {}
- // Only Mistral Small 4 and Medium 3.5 support reasoning
- const MISTRAL_REASONING_IDS = [
- "mistral-small-2603",
- "mistral-small-latest",
- "mistral-medium-3.5",
- "mistral-medium-2604",
- ]
- const mistralId = model.api.id.toLowerCase()
- if (!MISTRAL_REASONING_IDS.some((id) => mistralId.includes(id))) return {}
- return {
- high: { reasoningEffort: "high" },
- }
- case "@ai-sdk/cohere":
- // https://v5.ai-sdk.dev/providers/ai-sdk-providers/cohere
- return {}
- case "@ai-sdk/groq":
- // https://v5.ai-sdk.dev/providers/ai-sdk-providers/groq
- const groqEffort = ["none", ...WIDELY_SUPPORTED_EFFORTS]
- return Object.fromEntries(
- groqEffort.map((effort) => [
- effort,
- {
- reasoningEffort: effort,
- },
- ]),
- )
- case "@ai-sdk/perplexity":
- // https://v5.ai-sdk.dev/providers/ai-sdk-providers/perplexity
- return {}
- case "@jerome-benoit/sap-ai-provider-v2":
- if (model.api.id.includes("anthropic")) {
- if (adaptiveEfforts) {
- return Object.fromEntries(
- adaptiveEfforts.map((effort) => [
- effort,
- {
- thinking: {
- type: "adaptive",
- ...(adaptiveOpus ? { display: "summarized" } : {}),
- },
- effort,
- },
- ]),
- )
- }
- return {
- high: {
- thinking: {
- type: "enabled",
- budgetTokens: 16000,
- },
- },
- max: {
- thinking: {
- type: "enabled",
- budgetTokens: 31999,
- },
- },
- }
- }
- if (model.api.id.includes("gemini") && id.includes("2.5")) {
- return {
- high: {
- thinkingConfig: {
- includeThoughts: true,
- thinkingBudget: 16000,
- },
- },
- max: {
- thinkingConfig: {
- includeThoughts: true,
- thinkingBudget: 24576,
- },
- },
- }
- }
- if (model.api.id.includes("gpt") || /\bo[1-9]/.test(model.api.id)) {
- return Object.fromEntries(WIDELY_SUPPORTED_EFFORTS.map((effort) => [effort, { reasoningEffort: effort }]))
- }
- return {}
- }
- return {}
- }
- export function options(input: {
- model: Provider.Model
- sessionID: string
- providerOptions?: Record<string, any>
- }): Record<string, any> {
- const result: Record<string, any> = {}
- if (
- input.model.api.npm === "@ai-sdk/google-vertex/anthropic" ||
- (!input.model.api.id.includes("claude") && input.model.api.npm === "@ai-sdk/anthropic")
- ) {
- result["toolStreaming"] = false
- }
- // openai and providers using openai package should set store to false by default.
- if (
- input.model.providerID === "openai" ||
- input.model.api.npm === "@ai-sdk/openai" ||
- input.model.api.npm === "@ai-sdk/github-copilot"
- ) {
- result["store"] = false
- }
- if (input.model.api.npm === "@ai-sdk/azure") {
- result["store"] = false
- result["promptCacheKey"] = input.sessionID
- }
- if (input.model.api.npm === "@openrouter/ai-sdk-provider" || input.model.api.npm === "@llmgateway/ai-sdk-provider") {
- result["usage"] = {
- include: true,
- }
- if (input.model.api.id.includes("gemini-3")) {
- result["reasoning"] = { effort: "high" }
- }
- }
- if (
- input.model.providerID === "baseten" ||
- (input.model.providerID === "opencode" && ["kimi-k2-thinking", "glm-4.6"].includes(input.model.api.id))
- ) {
- result["chat_template_args"] = { enable_thinking: true }
- }
- if (
- ["zai", "zhipuai"].some((id) => input.model.providerID.includes(id)) &&
- input.model.api.npm === "@ai-sdk/openai-compatible"
- ) {
- result["thinking"] = {
- type: "enabled",
- clear_thinking: false,
- }
- }
- if (input.model.providerID === "openai" || input.providerOptions?.setCacheKey) {
- result["promptCacheKey"] = input.sessionID
- }
- if (input.model.api.npm === "@ai-sdk/google" || input.model.api.npm === "@ai-sdk/google-vertex") {
- if (input.model.capabilities.reasoning) {
- result["thinkingConfig"] = {
- includeThoughts: true,
- }
- if (input.model.api.id.includes("gemini-3")) {
- result["thinkingConfig"]["thinkingLevel"] = "high"
- }
- }
- }
- // Enable thinking by default for kimi models using anthropic SDK
- const modelId = input.model.api.id.toLowerCase()
- if (
- (input.model.api.npm === "@ai-sdk/anthropic" || input.model.api.npm === "@ai-sdk/google-vertex/anthropic") &&
- (modelId.includes("k2p") || modelId.includes("kimi-k2.") || modelId.includes("kimi-k2p"))
- ) {
- result["thinking"] = {
- type: "enabled",
- budgetTokens: Math.min(16_000, Math.floor(input.model.limit.output / 2 - 1)),
- }
- }
- // Enable thinking for reasoning models on alibaba-cn (DashScope).
- // DashScope's OpenAI-compatible API requires `enable_thinking: true` in the request body
- // to return reasoning_content. Without it, models like kimi-k2.5, qwen-plus, qwen3, qwq,
- // deepseek-r1, etc. never output thinking/reasoning tokens.
- // Note: kimi-k2-thinking is excluded as it returns reasoning_content by default.
- if (
- input.model.providerID === "alibaba-cn" &&
- input.model.capabilities.reasoning &&
- input.model.api.npm === "@ai-sdk/openai-compatible" &&
- !modelId.includes("kimi-k2-thinking")
- ) {
- result["enable_thinking"] = true
- }
- if (input.model.api.npm === "@ai-sdk/azure" && input.model.api.id.includes("gpt-5.5")) {
- result["reasoningSummary"] = "auto"
- return result
- }
- if (input.model.api.id.includes("gpt-5") && !input.model.api.id.includes("gpt-5-chat")) {
- if (!input.model.api.id.includes("gpt-5-pro")) {
- result["reasoningEffort"] = "medium"
- result["reasoningSummary"] = "auto"
- if (input.model.api.npm === "@ai-sdk/openai") {
- result["include"] = INCLUDE_ENCRYPTED_REASONING
- }
- }
- // Only set textVerbosity for non-chat gpt-5.x models
- // Chat models (e.g. gpt-5.2-chat-latest) only support "medium" verbosity
- if (
- input.model.api.id.includes("gpt-5.") &&
- !input.model.api.id.includes("codex") &&
- !input.model.api.id.includes("-chat") &&
- input.model.providerID !== "azure"
- ) {
- result["textVerbosity"] = "low"
- }
- if (input.model.providerID.startsWith("opencode")) {
- result["promptCacheKey"] = input.sessionID
- result["include"] = INCLUDE_ENCRYPTED_REASONING
- result["reasoningSummary"] = "auto"
- }
- }
- if (input.model.providerID === "venice") {
- result["promptCacheKey"] = input.sessionID
- }
- if (input.model.providerID === "openrouter") {
- result["prompt_cache_key"] = input.sessionID
- }
- if (input.model.api.npm === "@ai-sdk/gateway") {
- result["gateway"] = {
- caching: "auto",
- }
- }
- return result
- }
- export function smallOptions(model: Provider.Model) {
- const small = Object.values(model.variants ?? {})[0] ?? {}
- if (
- model.providerID === "openai" ||
- model.api.npm === "@ai-sdk/openai" ||
- model.api.npm === "@ai-sdk/github-copilot"
- ) {
- const base = { store: false }
- return mergeDeep(base, small)
- }
- if (model.providerID === "openrouter" || model.providerID === "llmgateway") {
- if (Object.keys(small).length === 0 && model.api.id.includes("google")) {
- return { reasoning: { enabled: false } }
- }
- }
- if (model.providerID === "venice") {
- if (Object.keys(small).length > 0) return small
- return { veniceParameters: { disableThinking: true } }
- }
- return small
- }
- // Maps model ID prefix to provider slug used in providerOptions.
- // Example: "amazon/nova-2-lite" → "bedrock"
- const SLUG_OVERRIDES: Record<string, string> = {
- amazon: "bedrock",
- }
- export function providerOptions(model: Provider.Model, options: { [x: string]: any }) {
- if (model.api.npm === "@ai-sdk/gateway") {
- // Gateway providerOptions are split across two namespaces:
- // - `gateway`: gateway-native routing/caching controls (order, only, byok, etc.)
- // - `<upstream slug>`: provider-specific model options (anthropic/openai/...)
- // We keep `gateway` as-is and route every other top-level option under the
- // model-derived upstream slug.
- const i = model.api.id.indexOf("/")
- const rawSlug = i > 0 ? model.api.id.slice(0, i) : undefined
- const slug = rawSlug ? (SLUG_OVERRIDES[rawSlug] ?? rawSlug) : undefined
- const gateway = options.gateway
- const rest = Object.fromEntries(Object.entries(options).filter(([k]) => k !== "gateway"))
- const has = Object.keys(rest).length > 0
- const result: Record<string, any> = {}
- if (gateway !== undefined) result.gateway = gateway
- if (has) {
- if (slug) {
- // Route model-specific options under the provider slug
- result[slug] = rest
- } else if (gateway && typeof gateway === "object" && !Array.isArray(gateway)) {
- result.gateway = { ...gateway, ...rest }
- } else {
- result.gateway = rest
- }
- }
- return result
- }
- // AI SDK packages that resolve providerOptionsName by splitting the
- // provider name on "." (e.g. "wafer.ai" -> "wafer") need the same
- // logic here so the key we write matches the key they read.
- // Other SDKs (xai, mistral, groq, cohere, etc.) use hardcoded keys
- // like "xai" or "cohere" - applying .split(".")[0] would break those.
- const usesDotSplitOptions =
- model.api.npm === "@ai-sdk/openai-compatible" ||
- model.api.npm === "@ai-sdk/openai" ||
- model.api.npm === "@ai-sdk/anthropic"
- const key = sdkKey(model.api.npm) ?? (usesDotSplitOptions ? model.providerID.split(".")[0] : model.providerID)
- // @ai-sdk/azure delegates to OpenAIChatLanguageModel which reads from
- // providerOptions["openai"], but OpenAIResponsesLanguageModel checks
- // "azure" first. Pass both so model options work on either code path.
- if (model.api.npm === "@ai-sdk/azure") {
- return { openai: options, azure: options }
- }
- return { [key]: options }
- }
- export function maxOutputTokens(model: Provider.Model, outputTokenMax = OUTPUT_TOKEN_MAX): number {
- return Math.min(model.limit.output, outputTokenMax) || outputTokenMax
- }
- export function schema(model: Provider.Model, schema: JSONSchema7): JSONSchema7 {
- /*
- if (["openai", "azure"].includes(providerID)) {
- if (schema.type === "object" && schema.properties) {
- for (const [key, value] of Object.entries(schema.properties)) {
- if (schema.required?.includes(key)) continue
- schema.properties[key] = {
- anyOf: [
- value as JSONSchema.JSONSchema,
- {
- type: "null",
- },
- ],
- }
- }
- }
- }
- */
- if (model.providerID === "moonshotai" || model.api.id.toLowerCase().includes("kimi")) {
- const sanitizeMoonshot = (obj: unknown): unknown => {
- if (obj === null || typeof obj !== "object") return obj
- if (Array.isArray(obj)) return obj.map(sanitizeMoonshot)
- // Moonshot expands $ref before validation and rejects sibling keywords like description on the same node.
- if ("$ref" in obj && typeof obj.$ref === "string") return { $ref: obj.$ref }
- const result = Object.fromEntries(Object.entries(obj).map(([key, value]) => [key, sanitizeMoonshot(value)]))
- // MFJS does not support tuple-style `items` arrays; it requires one schema object for all array items.
- if (Array.isArray(result.items)) result.items = result.items[0] ?? {}
- return result
- }
- const sanitized = sanitizeMoonshot(schema)
- if (typeof sanitized === "object" && sanitized !== null && !Array.isArray(sanitized)) {
- schema = sanitized
- }
- }
- // Convert integer enums to string enums for Google/Gemini
- if (model.providerID === "google" || model.api.id.includes("gemini")) {
- const isPlainObject = (node: unknown): node is Record<string, any> =>
- typeof node === "object" && node !== null && !Array.isArray(node)
- const hasCombiner = (node: unknown) =>
- isPlainObject(node) && (Array.isArray(node.anyOf) || Array.isArray(node.oneOf) || Array.isArray(node.allOf))
- const hasSchemaIntent = (node: unknown) => {
- if (!isPlainObject(node)) return false
- if (hasCombiner(node)) return true
- return [
- "type",
- "properties",
- "items",
- "prefixItems",
- "enum",
- "const",
- "$ref",
- "additionalProperties",
- "patternProperties",
- "required",
- "not",
- "if",
- "then",
- "else",
- ].some((key) => key in node)
- }
- const sanitizeGemini = (obj: any): any => {
- if (obj === null || typeof obj !== "object") {
- return obj
- }
- if (Array.isArray(obj)) {
- return obj.map(sanitizeGemini)
- }
- const result: any = {}
- for (const [key, value] of Object.entries(obj)) {
- if (key === "enum" && Array.isArray(value)) {
- // Convert all enum values to strings
- result[key] = value.map((v) => String(v))
- // If we have integer type with enum, change type to string
- if (result.type === "integer" || result.type === "number") {
- result.type = "string"
- }
- } else if (typeof value === "object" && value !== null) {
- result[key] = sanitizeGemini(value)
- } else {
- result[key] = value
- }
- }
- // Filter required array to only include fields that exist in properties
- if (result.type === "object" && result.properties && Array.isArray(result.required)) {
- result.required = result.required.filter((field: any) => field in result.properties)
- }
- if (result.type === "array" && !hasCombiner(result)) {
- if (result.items == null) {
- result.items = {}
- }
- // Ensure items has a type only when it's still schema-empty.
- if (isPlainObject(result.items) && !hasSchemaIntent(result.items)) {
- result.items.type = "string"
- }
- }
- // Remove properties/required from non-object types (Gemini rejects these)
- if (result.type && result.type !== "object" && !hasCombiner(result)) {
- delete result.properties
- delete result.required
- }
- return result
- }
- schema = sanitizeGemini(schema)
- }
- return schema
- }
- export * as ProviderTransform from "./transform"
|