| 1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255125612571258125912601261126212631264126512661267126812691270127112721273127412751276127712781279128012811282128312841285128612871288128912901291129212931294129512961297129812991300130113021303130413051306130713081309131013111312131313141315131613171318131913201321132213231324132513261327132813291330133113321333133413351336133713381339134013411342134313441345134613471348134913501351135213531354135513561357135813591360136113621363136413651366136713681369137013711372137313741375137613771378137913801381138213831384 |
- import type { ModelMessage, ToolResultPart } from "ai"
- import { mergeDeep, unique } from "remeda"
- import type { JSONSchema7 } from "@ai-sdk/provider"
- import type { JSONSchema } from "zod/v4/core"
- import type * as Provider from "./provider"
- import type * as ModelsDev from "./models"
- import { iife } from "@/util/iife"
- import { Flag } from "@opencode-ai/core/flag/flag"
- 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 = Flag.OPENCODE_EXPERIMENTAL_OUTPUT_TOKEN_MAX || 32_000
- 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 anthropicAdaptiveEfforts(apiId: string): string[] | null {
- if (["opus-4-7", "opus-4.7"].some((v) => apiId.includes(v))) {
- return ["low", "medium", "high", "xhigh", "max"]
- }
- if (["opus-4-6", "opus-4.6", "sonnet-4-6", "sonnet-4.6"].some((v) => apiId.includes(v))) {
- return ["low", "medium", "high", "max"]
- }
- return null
- }
- export function variants(model: Provider.Model): Record<string, Record<string, any>> {
- if (!model.capabilities.reasoning) return {}
- const id = model.id.toLowerCase()
- 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",
- },
- 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: ["reasoning.encrypted_content"],
- },
- ]),
- )
- 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: ["reasoning.encrypted_content"],
- },
- ]),
- )
- 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: ["reasoning.encrypted_content"],
- },
- ]),
- )
- }
- 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",
- ...(model.api.id.includes("opus-4-7") || model.api.id.includes("opus-4.7")
- ? { 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,
- ...(model.api.id.includes("opus-4-7") || model.api.id.includes("opus-4.7")
- ? { 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: 24576,
- },
- },
- }
- }
- let levels = ["low", "high"]
- if (id.includes("3.1")) {
- levels = ["low", "medium", "high"]
- }
- return Object.fromEntries(
- levels.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",
- },
- 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.id.includes("gpt-5") && !input.model.api.id.includes("gpt-5-chat")) {
- if (!input.model.api.id.includes("gpt-5-pro")) {
- result["reasoningEffort"] = "medium"
- // Only inject reasoningSummary for providers that support it natively.
- // @ai-sdk/openai-compatible proxies (e.g. LiteLLM) do not understand this
- // parameter and return "Unknown parameter: 'reasoningSummary'".
- if (
- input.model.api.npm === "@ai-sdk/openai" ||
- input.model.api.npm === "@ai-sdk/azure" ||
- input.model.api.npm === "@ai-sdk/github-copilot"
- ) {
- result["reasoningSummary"] = "auto"
- }
- }
- // 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"] = ["reasoning.encrypted_content"]
- 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) {
- if (
- model.providerID === "openai" ||
- model.api.npm === "@ai-sdk/openai" ||
- model.api.npm === "@ai-sdk/github-copilot"
- ) {
- if (model.api.id.includes("gpt-5")) {
- if (model.api.id.includes("-chat")) {
- if (gpt5Version(model.api.id) === undefined) return { store: false }
- return { store: false, reasoningEffort: "medium" }
- }
- if (model.api.id.includes("search-api")) return { store: false }
- if (model.api.id.includes("5.") || model.api.id.includes("5-mini")) {
- return { store: false, reasoningEffort: "low" }
- }
- return { store: false, reasoningEffort: "minimal" }
- }
- return { store: false }
- }
- if (model.providerID === "google") {
- // gemini-3 uses thinkingLevel, gemini-2.5 uses thinkingBudget
- if (model.api.id.includes("gemini-3")) {
- return { thinkingConfig: { thinkingLevel: "minimal" } }
- }
- return { thinkingConfig: { thinkingBudget: 0 } }
- }
- if (model.providerID === "openrouter" || model.providerID === "llmgateway") {
- if (model.api.id.includes("google")) {
- return { reasoning: { enabled: false } }
- }
- return { reasoningEffort: "minimal" }
- }
- if (model.providerID === "venice") {
- return { veniceParameters: { disableThinking: true } }
- }
- return {}
- }
- // 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): number {
- return Math.min(model.limit.output, OUTPUT_TOKEN_MAX) || OUTPUT_TOKEN_MAX
- }
- export function schema(model: Provider.Model, schema: JSONSchema.BaseSchema | 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
- }
- schema = sanitizeMoonshot(schema) as JSONSchema.BaseSchema | JSONSchema7
- }
- // 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 as JSONSchema7
- }
- export * as ProviderTransform from "./transform"
|