shared.ts 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326
  1. import { Buffer } from "node:buffer"
  2. import { Effect, Schema, Stream } from "effect"
  3. import * as Sse from "effect/unstable/encoding/Sse"
  4. import { Headers, HttpClientRequest } from "effect/unstable/http"
  5. import {
  6. InvalidProviderOutputReason,
  7. InvalidRequestReason,
  8. LLMError,
  9. type ContentPart,
  10. type LLMRequest,
  11. type MediaPart,
  12. type ToolFileContent,
  13. type TextPart,
  14. type ToolResultPart,
  15. } from "../schema"
  16. import { isRecord } from "../utils/record"
  17. export { isRecord }
  18. export const Json = Schema.fromJsonString(Schema.Unknown)
  19. export const decodeJson = Schema.decodeUnknownSync(Json)
  20. export const encodeJson = Schema.encodeSync(Json)
  21. const isJson = Schema.is(Schema.Json)
  22. export const JsonObject = Schema.Record(Schema.String, Schema.Unknown)
  23. export const optionalArray = <const S extends Schema.Top>(schema: S) => Schema.optional(Schema.Array(schema))
  24. export const optionalNull = <const S extends Schema.Top>(schema: S) => Schema.optional(Schema.NullOr(schema))
  25. /**
  26. * Streaming tool-call accumulator. Adapters that build a tool call across
  27. * multiple `tool-input-delta` chunks store the partial JSON input string here
  28. * and finalize it with `parseToolInput` once the call completes.
  29. */
  30. export interface ToolAccumulator {
  31. readonly id: string
  32. readonly name: string
  33. readonly input: string
  34. }
  35. /**
  36. * `Usage.totalTokens` policy shared by every route. Honors a provider-
  37. * supplied total; otherwise falls back to `inputTokens + outputTokens` only
  38. * when at least one is defined. Returns `undefined` when neither input nor
  39. * output is known so routes don't publish a misleading `0`.
  40. *
  41. * Under the additive `LLM.Usage` contract, `inputTokens` and `outputTokens`
  42. * are the non-cached input and visible output only. The provider-supplied
  43. * `total` is the source of truth when present; the computed fallback
  44. * under-counts cache and reasoning by design and exists mainly so
  45. * Anthropic-style providers (which don't surface a total) still get a
  46. * sensible aggregate on the input + output axes.
  47. */
  48. export const totalTokens = (
  49. inputTokens: number | undefined,
  50. outputTokens: number | undefined,
  51. total: number | undefined,
  52. ) => {
  53. if (total !== undefined) return total
  54. if (inputTokens === undefined && outputTokens === undefined) return undefined
  55. return (inputTokens ?? 0) + (outputTokens ?? 0)
  56. }
  57. /**
  58. * Subtract `subtrahend` from `total`, clamping to zero if the provider
  59. * reports a non-sensical breakdown (e.g. `cached_tokens > prompt_tokens`).
  60. * Used by protocol mappers when deriving a non-overlapping breakdown field
  61. * from a provider's inclusive total — `nonCachedInputTokens` from
  62. * `inputTokens - cacheReadInputTokens - cacheWriteInputTokens`.
  63. *
  64. * If `total` is `undefined`, returns `undefined` (we don't fabricate
  65. * counts). If `subtrahend` is `undefined`, returns `total` unchanged. The
  66. * provider-native breakdown stays available on `Usage.native` for debugging.
  67. */
  68. export const subtractTokens = (total: number | undefined, subtrahend: number | undefined): number | undefined => {
  69. if (total === undefined) return undefined
  70. if (subtrahend === undefined) return total
  71. return Math.max(0, total - subtrahend)
  72. }
  73. /**
  74. * Sum a list of optional token counts, returning `undefined` only when
  75. * every value is `undefined` (so we don't fabricate a `0`). Used by
  76. * protocol mappers to derive the inclusive `inputTokens` total from a
  77. * provider that natively reports a non-overlapping breakdown
  78. * (e.g. Anthropic, whose `input_tokens` is already non-cached only).
  79. */
  80. export const sumTokens = (...values: ReadonlyArray<number | undefined>): number | undefined => {
  81. if (values.every((value) => value === undefined)) return undefined
  82. return values.reduce((acc: number, value) => acc + (value ?? 0), 0)
  83. }
  84. export const eventError = (route: string, message: string, raw?: string) =>
  85. new LLMError({
  86. module: "ProviderShared",
  87. method: "stream",
  88. reason: new InvalidProviderOutputReason({ route, message, raw }),
  89. })
  90. export const parseJson = (route: string, input: string, message: string) =>
  91. Effect.try({
  92. try: () => decodeJson(input),
  93. catch: () => eventError(route, message, input),
  94. })
  95. /**
  96. * Join the `text` field of a list of parts with newlines. Used by routes
  97. * that flatten system / message content arrays into a single provider string
  98. * (OpenAI Chat `system` content, OpenAI Responses `system` content, Gemini
  99. * `systemInstruction.parts[].text`).
  100. */
  101. export const joinText = (parts: ReadonlyArray<{ readonly text: string }>) => parts.map((part) => part.text).join("\n")
  102. const escapeSystemUpdateText = (text: string) =>
  103. text.replaceAll("&", "&amp;").replaceAll("<", "&lt;").replaceAll(">", "&gt;")
  104. /**
  105. * Stable fallback representation for chronological `Message.system(...)`
  106. * updates on routes that do not support that privileged role natively. The
  107. * wrapper remains visibly lower-authority user text, preserves the original
  108. * temporal position, and XML-escapes content so it cannot close the wrapper.
  109. */
  110. export const wrapSystemUpdate = (parts: ReadonlyArray<{ readonly text: string }>) =>
  111. `<system-update>\n${escapeSystemUpdateText(joinText(parts))}\n</system-update>`
  112. /**
  113. * Chronological system updates deliberately accept text only. Do not insert
  114. * raw retrieved, tool, or web content into privileged updates: keep untrusted
  115. * data in ordinary user/tool messages instead.
  116. */
  117. export const systemUpdateText = Effect.fn("ProviderShared.systemUpdateText")(function* (
  118. route: string,
  119. message: LLMRequest["messages"][number],
  120. ) {
  121. const content: TextPart[] = []
  122. for (const part of message.content) {
  123. if (!supportsContent(part, ["text"])) return yield* unsupportedContent(route, "system", ["text"])
  124. content.push(part)
  125. }
  126. return content
  127. })
  128. /** Lower an unsupported privileged update into visible, in-order user text. */
  129. export const wrappedSystemUpdate = Effect.fn("ProviderShared.wrappedSystemUpdate")(function* (
  130. route: string,
  131. message: LLMRequest["messages"][number],
  132. ) {
  133. const content = yield* systemUpdateText(route, message)
  134. return { type: "text" as const, text: wrapSystemUpdate(content), cache: content.at(-1)?.cache }
  135. })
  136. /**
  137. * Parse the streamed JSON input of a tool call. Treats an empty string as
  138. * `"{}"` — providers occasionally finish a tool call without ever emitting
  139. * input deltas (e.g. zero-arg tools). The error message is uniform across
  140. * routes: `Invalid JSON input for <route> tool call <name>`.
  141. */
  142. export const parseToolInput = (route: string, name: string, raw: string) =>
  143. parseJson(route, raw || "{}", `Invalid JSON input for ${route} tool call ${name}`)
  144. export const IMAGE_MIMES = ["image/png", "image/jpeg", "image/gif", "image/webp"] as const
  145. export const VIDEO_MIMES = ["video/mp4", "video/webm", "video/quicktime"] as const
  146. export const AUDIO_MIMES = ["audio/wav", "audio/mp3", "audio/aiff", "audio/aac", "audio/ogg", "audio/flac"] as const
  147. export const MEDIA_MIMES = [...IMAGE_MIMES, ...VIDEO_MIMES, ...AUDIO_MIMES] as const
  148. export const MAX_MEDIA_ENCODED_BYTES = 28 * 1024 * 1024
  149. export const MAX_MEDIA_DECODED_BYTES = 20 * 1024 * 1024
  150. const base64Pattern = /^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/
  151. export interface ValidatedMedia {
  152. readonly mime: string
  153. readonly base64: string
  154. readonly dataUrl: string
  155. readonly bytes: Uint8Array
  156. }
  157. export const validateMedia = Effect.fn("ProviderShared.validateMedia")(function* (
  158. route: string,
  159. part: MediaPart,
  160. supportedMimes: ReadonlySet<string>,
  161. ) {
  162. const mime = part.mediaType.toLowerCase()
  163. if (!supportedMimes.has(mime)) return yield* invalidRequest(`${route} does not support media type ${part.mediaType}`)
  164. let base64: string
  165. if (typeof part.data !== "string") {
  166. if (part.data.byteLength > MAX_MEDIA_DECODED_BYTES)
  167. return yield* invalidRequest(`${route} media exceeds the ${MAX_MEDIA_DECODED_BYTES} byte decoded limit`)
  168. base64 = Buffer.from(part.data).toString("base64")
  169. } else if (part.data.startsWith("data:")) {
  170. const match = /^data:([^;,]+);base64,([A-Za-z0-9+/]*={0,2})$/s.exec(part.data)
  171. if (!match) return yield* invalidRequest(`${route} media data URL must contain valid base64`)
  172. if (match[1]!.toLowerCase() !== mime)
  173. return yield* invalidRequest(`${route} media type ${part.mediaType} does not match data URL type ${match[1]}`)
  174. base64 = match[2]!
  175. } else {
  176. base64 = part.data
  177. }
  178. if (Buffer.byteLength(base64, "utf8") > MAX_MEDIA_ENCODED_BYTES)
  179. return yield* invalidRequest(`${route} media exceeds the ${MAX_MEDIA_ENCODED_BYTES} byte encoded limit`)
  180. if (!base64 || base64.length % 4 !== 0 || !base64Pattern.test(base64))
  181. return yield* invalidRequest(`${route} media must contain valid base64`)
  182. const bytes = Buffer.from(base64, "base64")
  183. if (bytes.byteLength > MAX_MEDIA_DECODED_BYTES)
  184. return yield* invalidRequest(`${route} media exceeds the ${MAX_MEDIA_DECODED_BYTES} byte decoded limit`)
  185. if (bytes.toString("base64") !== base64) return yield* invalidRequest(`${route} media must contain canonical base64`)
  186. return { mime, base64, dataUrl: `data:${mime};base64,${base64}`, bytes } satisfies ValidatedMedia
  187. })
  188. export const validateToolFile = (route: string, part: ToolFileContent, supportedMimes: ReadonlySet<string>) =>
  189. validateMedia(route, { type: "media", mediaType: part.mime, data: part.uri, filename: part.name }, supportedMimes)
  190. export const trimBaseUrl = (value: string) => value.replace(/\/+$/, "")
  191. export const toolResultText = (part: ToolResultPart) => {
  192. if (part.result.type === "text") return String(part.result.value)
  193. if (part.result.type === "error") {
  194. const value = part.result.value
  195. const prototype =
  196. typeof value === "object" && value !== null && !Array.isArray(value) && Object.getPrototypeOf(value)
  197. const structured = Array.isArray(value) || prototype === Object.prototype || prototype === null
  198. return structured && isJson(value) ? encodeJson(value) : String(value)
  199. }
  200. return encodeJson(part.result.value)
  201. }
  202. export const errorText = (error: unknown) => {
  203. if (error instanceof Error) return error.message
  204. if (typeof error === "string") return error
  205. if (typeof error === "number" || typeof error === "boolean" || typeof error === "bigint") return String(error)
  206. if (error === null) return "null"
  207. if (error === undefined) return "undefined"
  208. return "Unknown stream error"
  209. }
  210. /**
  211. * `framing` step for Server-Sent Events. Decodes UTF-8, runs the SSE channel
  212. * decoder, and drops empty / `[DONE]` keep-alive events so the downstream
  213. * `decodeChunk` sees one JSON string per element. The SSE channel emits a
  214. * `Retry` control event on its error channel; we drop it here (we don't
  215. * implement client-driven retries) so the public error channel stays
  216. * `LLMError`.
  217. */
  218. export const sseFraming = (bytes: Stream.Stream<Uint8Array, LLMError>): Stream.Stream<string, LLMError> =>
  219. bytes.pipe(
  220. Stream.decodeText(),
  221. Stream.pipeThroughChannel(Sse.decode()),
  222. Stream.catchTag("Retry", () => Stream.empty),
  223. Stream.filter((event) => event.data.length > 0 && event.data !== "[DONE]"),
  224. Stream.map((event) => event.data),
  225. )
  226. /**
  227. * Canonical invalid-request constructor. Lift one-line `const invalid =
  228. * (message) => invalidRequest(message)` aliases out of every
  229. * route so the error constructor lives in one place. If we ever extend
  230. * `InvalidRequestReason` with route context or trace metadata, the change
  231. * lands here.
  232. */
  233. export const invalidRequest = (message: string) =>
  234. new LLMError({
  235. module: "ProviderShared",
  236. method: "request",
  237. reason: new InvalidRequestReason({ message }),
  238. })
  239. export const matchToolChoice = <Auto, None, Required, Tool>(
  240. route: string,
  241. toolChoice: NonNullable<LLMRequest["toolChoice"]>,
  242. cases: {
  243. readonly auto: () => Auto
  244. readonly none: () => None
  245. readonly required: () => Required
  246. readonly tool: (name: string) => Tool
  247. },
  248. ) =>
  249. Effect.gen(function* () {
  250. if (toolChoice.type === "auto") return cases.auto()
  251. if (toolChoice.type === "none") return cases.none()
  252. if (toolChoice.type === "required") return cases.required()
  253. if (!toolChoice.name) return yield* invalidRequest(`${route} tool choice requires a tool name`)
  254. return cases.tool(toolChoice.name)
  255. })
  256. type ContentType = ContentPart["type"]
  257. const formatContentTypes = (types: ReadonlyArray<ContentType>) => {
  258. if (types.length <= 1) return types[0] ?? ""
  259. if (types.length === 2) return `${types[0]} and ${types[1]}`
  260. return `${types.slice(0, -1).join(", ")}, and ${types.at(-1)}`
  261. }
  262. export const supportsContent = <const Type extends ContentType>(
  263. part: ContentPart,
  264. types: ReadonlyArray<Type>,
  265. ): part is Extract<ContentPart, { readonly type: Type }> => (types as ReadonlyArray<ContentType>).includes(part.type)
  266. export const unsupportedContent = (
  267. route: string,
  268. role: LLMRequest["messages"][number]["role"],
  269. types: ReadonlyArray<ContentType>,
  270. ) => invalidRequest(`${route} ${role} messages only support ${formatContentTypes(types)} content for now`)
  271. /**
  272. * Build a `validate` step from a Schema decoder. Replaces the per-route
  273. * lambda body `(payload) => decode(payload).pipe(Effect.mapError((e) =>
  274. * invalid(e.message)))`. Any decode error is translated into
  275. * `LLMError` carrying the original parse-error message.
  276. */
  277. export const validateWith =
  278. <A, I, E extends { readonly message: string }>(decode: (input: I) => Effect.Effect<A, E>) =>
  279. (payload: I) =>
  280. decode(payload).pipe(Effect.mapError((error) => invalidRequest(error.message)))
  281. /**
  282. * Build an HTTP POST with a JSON body. Sets `content-type: application/json`
  283. * automatically after caller-supplied headers so routes cannot accidentally
  284. * send JSON with a stale content type. The body is passed pre-encoded so
  285. * routes can choose between
  286. * `Schema.encodeSync(payload)` and `ProviderShared.encodeJson(payload)`.
  287. */
  288. export const jsonPost = (input: { readonly url: string; readonly body: string; readonly headers?: Headers.Input }) =>
  289. HttpClientRequest.post(input.url).pipe(
  290. HttpClientRequest.setHeaders(Headers.set(Headers.fromInput(input.headers), "content-type", "application/json")),
  291. HttpClientRequest.bodyText(input.body, "application/json"),
  292. )
  293. export * as ProviderShared from "./shared"