933 lines · 32.6 KB · TypeScript
RawDownload
import type { APICallError, ModelMessage } from "ai"import { mergeDeep, unique } from "remeda"import type { JSONSchema } from "zod/v4/core"import type { Provider } from "./provider"import type { ModelsDev } from "./models"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 namespace ProviderTransform {  // 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":      case "@ai-sdk/openai":      case "@ai-sdk/azure":        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":      case "@ai-sdk/google":        return "google"      case "@ai-sdk/gateway":        return "gateway"      case "@openrouter/ai-sdk-provider":        return "openrouter"    }    return undefined  }   function normalizeMessages(    msgs: ModelMessage[],    model: Provider.Model,    options: Record<string, unknown>,  ): ModelMessage[] {    // 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" || part.type === "reasoning") {              return part.text !== ""            }            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")) {      return msgs.map((msg) => {        if ((msg.role === "assistant" || msg.role === "tool") && Array.isArray(msg.content)) {          msg.content = msg.content.map((part) => {            if ((part.type === "tool-call" || part.type === "tool-result") && "toolCallId" in part) {              return {                ...part,                toolCallId: part.toolCallId.replace(/[^a-zA-Z0-9_-]/g, "_"),              }            }            return part          })        }        return msg      })    }    if (model.providerID === "mistral" || model.api.id.toLowerCase().includes("mistral")) {      const result: ModelMessage[] = []      for (let i = 0; i < msgs.length; i++) {        const msg = msgs[i]        const nextMsg = msgs[i + 1]         if ((msg.role === "assistant" || msg.role === "tool") && Array.isArray(msg.content)) {          msg.content = msg.content.map((part) => {            if ((part.type === "tool-call" || part.type === "tool-result") && "toolCallId" in part) {              // Mistral requires alphanumeric tool call IDs with exactly 9 characters              const normalizedId = part.toolCallId                .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               return {                ...part,                toolCallId: normalizedId,              }            }            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    }     if (typeof model.capabilities.interleaved === "object" && model.capabilities.interleaved.field) {      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          if (reasoningText) {            return {              ...msg,              content: filteredContent,              providerOptions: {                ...msg.providerOptions,                openaiCompatible: {                  ...(msg.providerOptions as any)?.openaiCompatible,                  [field]: reasoningText,                },              },            }          }           return {            ...msg,            content: filteredContent,          }        }         return msg      })    }     return msgs  }   function applyCaching(msgs: ModelMessage[], providerID: string): 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: "ephemeral" },      },      openaiCompatible: {        cache_control: { type: "ephemeral" },      },    }     for (const msg of unique([...system, ...final])) {      const shouldUseContentOptions = providerID !== "anthropic" && Array.isArray(msg.content) && msg.content.length > 0       if (shouldUseContentOptions) {        const lastContent = msg.content[msg.content.length - 1]        if (lastContent && typeof lastContent === "object") {          lastContent.providerOptions = mergeDeep(lastContent.providerOptions ?? {}, providerOptions)          continue        }      }       msg.providerOptions = mergeDeep(msg.providerOptions ?? {}, providerOptions)    }     return msgs  }   // Pull raw bytes out of a file/media part's data, whatever shape it arrives  // in (data URL, raw base64, Uint8Array/ArrayBuffer). Returns null for things  // we can't read inline (e.g. an http URL).  function partBytes(data: unknown): Uint8Array | null {    if (data instanceof Uint8Array) return data    if (data instanceof ArrayBuffer) return new Uint8Array(data)    if (typeof data === "string") {      if (/^https?:\/\//i.test(data)) return null      const b64 = data.startsWith("data:") ? data.slice(data.indexOf(",") + 1) : data      try {        return new Uint8Array(Buffer.from(b64, "base64"))      } catch {        return null      }    }    return null  }   // Decode bytes as UTF-8, or null if they aren't valid text (binary).  function asText(bytes: Uint8Array): string | null {    if (bytes.includes(0)) return null    try {      return new TextDecoder("utf-8", { fatal: true }).decode(bytes)    } catch {      return null    }  }   // A file the model can't ingest as a known modality (e.g. .pem/x509, source  // code, config, application/octet-stream). Sending it through as a file part  // throws "AI_UnsupportedFunctionalityError: ... media type not supported" on  // most providers — which poisons the whole send. Inline the content as text  // when it's decodable; otherwise leave a short note so the model can proceed.  const INLINE_LIMIT = 64_000  function fileToText(part: any): { type: "text"; text: string } {    const name = part.filename ? `"${part.filename}"` : "file"    const mime = part.mediaType ?? "application/octet-stream"    const bytes = partBytes(part.data ?? part.url)    const text = bytes ? asText(bytes) : null    if (text !== null) {      const body =        text.length > INLINE_LIMIT ? text.slice(0, INLINE_LIMIT) + `\n…[truncated; ${text.length} chars total]` : text      return { type: "text", text: `Attached file ${name} (${mime}):\n\n${body}` }    }    return {      type: "text",      text: `[Attached file ${name} (${mime}) is binary and can't be shown inline. If its contents are needed, ask the user to paste them or read it from .context/.]`,    }  }   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 = part.image.toString()          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" ? part.image.toString().split(";")[0].replace("data:", "") : part.mediaType        const filename = part.type === "file" ? part.filename : undefined        const modality = mimeToModality(mime)        if (!modality) {          // No modality → the provider can't take this as a file part. For          // files, downgrade to text instead of crashing the request. (Images          // always map to a modality, so this only catches file parts.)          if (part.type === "file") return fileToText(part)          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 }    })  }   // Safety net: correct all image mime types from magic bytes before sending to any provider.  // Prevents "Image does not match the provided media type" errors that poison the entire conversation.  function correctImageMimes(msgs: ModelMessage[]): ModelMessage[] {    return msgs.map((msg): ModelMessage => {      if (!Array.isArray(msg.content)) return msg      let changed = false      const content = (msg.content as any[]).map((part: any) => {        if (part.type !== "file" && part.type !== "image") return part        const mime: string | undefined = part.mediaType ?? part.mimeType        if (!mime?.startsWith("image/")) return part        // Extract base64 from data URL or raw data        const data: unknown = part.data ?? part.image        if (!data) return part        const str = typeof data === "string" ? data : data instanceof URL ? data.toString() : ""        if (!str) return part        const b64 = str.includes(",") ? str.slice(str.indexOf(",") + 1) : str        if (b64.length < 16) return part        try {          const raw = atob(b64.slice(0, 24))          let detected: string | undefined          if (raw.charCodeAt(0) === 0x89 && raw.slice(1, 4) === "PNG") detected = "image/png"          else if (raw.charCodeAt(0) === 0xff && raw.charCodeAt(1) === 0xd8) detected = "image/jpeg"          else if (raw.length >= 12 && raw.slice(8, 12) === "WEBP") detected = "image/webp"          else if (raw.slice(0, 3) === "GIF") detected = "image/gif"          if (detected && detected !== mime) {            changed = true            const newPart = { ...part, mediaType: detected }            // Fix data URL prefix if present            if (typeof newPart.data === "string" && newPart.data.startsWith("data:"))              newPart.data = `data:${detected};base64,${b64}`            if (typeof newPart.image === "string" && newPart.image.startsWith("data:"))              newPart.image = `data:${detected};base64,${b64}`            if (newPart.image instanceof URL && newPart.image.toString().startsWith("data:"))              newPart.image = new URL(`data:${detected};base64,${b64}`)            return newPart          }        } catch {}        return part      })      return changed ? ({ ...msg, content } as ModelMessage) : msg    })  }   export function message(msgs: ModelMessage[], model: Provider.Model, options: Record<string, unknown>) {    msgs = unsupportedParts(msgs, model)    msgs = normalizeMessages(msgs, model, options)    msgs = correctImageMimes(msgs)    if (      model.providerID === "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"    ) {      msgs = applyCaching(msgs, model.providerID)    }     // Remap providerOptions keys from stored providerID to expected SDK key    const key = sdkKey(model.api.npm)    if (key && key !== model.providerID && model.api.npm !== "@ai-sdk/azure") {      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) => ({ ...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      if (id.includes("thinking") || id.includes("k2.") || id.includes("k2p")) {        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 (id.includes("minimax-m2") || id.includes("kimi-k2.5") || id.includes("kimi-k2p5") || id.includes("gemini")) {      return 0.95    }    return undefined  }   export function topK(model: Provider.Model) {    const id = model.id.toLowerCase()    if (id.includes("minimax-m2")) {      if (id.includes("m2.1")) 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_GPT55_EFFORTS = ["none", ...WIDELY_SUPPORTED_EFFORTS, "xhigh"]   export function variants(model: Provider.Model): Record<string, Record<string, any>> {    if (!model.capabilities.reasoning) return {}     const id = model.id.toLowerCase()    if (      id.includes("deepseek") ||      id.includes("minimax") ||      id.includes("glm") ||      id.includes("mistral") ||      id.includes("kimi")    )      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 (model.id.includes("gemini-3")) {          return Object.fromEntries(WIDELY_SUPPORTED_EFFORTS.map((effort) => [effort, { reasoning: { effort } }]))        }        if (!model.id.includes("gpt")) return {}        return Object.fromEntries(          (id.includes("gpt-5.5") ? OPENAI_GPT55_EFFORTS : OPENAI_EFFORTS).map((effort) => [            effort,            { reasoning: { effort } },          ]),        )       // NOTE: the gateway rejects max_tokens when reasoningEffort is set — the      // conflict is resolved in maxOutputTokens() (drops the cap for gateway      // calls carrying a reasoningEffort), so the effort variants are safe here.      case "@ai-sdk/gateway":        return Object.fromEntries(OPENAI_EFFORTS.map((effort) => [effort, { reasoningEffort: effort }]))       case "@ai-sdk/github-copilot":        const copilotEfforts = iife(() => {          if (id.includes("5.1-codex-max") || id.includes("5.2")) return [...WIDELY_SUPPORTED_EFFORTS, "xhigh"]          return WIDELY_SUPPORTED_EFFORTS        })        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 "@ai-sdk/openai-compatible":        return Object.fromEntries(WIDELY_SUPPORTED_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 {}        const azureEfforts = ["low", "medium", "high"]        if (id.includes("gpt-5-") || id === "gpt-5") {          azureEfforts.unshift("minimal")        }        return Object.fromEntries(

Lines 1–500 of 933.