shared.ts 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244
  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 ToolResultPart,
  13. } from "../schema"
  14. export { isRecord } from "../utils/record"
  15. export const Json = Schema.fromJsonString(Schema.Unknown)
  16. export const decodeJson = Schema.decodeUnknownSync(Json)
  17. export const encodeJson = Schema.encodeSync(Json)
  18. export const JsonObject = Schema.Record(Schema.String, Schema.Unknown)
  19. export const optionalArray = <const S extends Schema.Top>(schema: S) => Schema.optional(Schema.Array(schema))
  20. export const optionalNull = <const S extends Schema.Top>(schema: S) => Schema.optional(Schema.NullOr(schema))
  21. /**
  22. * Streaming tool-call accumulator. Adapters that build a tool call across
  23. * multiple `tool-input-delta` chunks store the partial JSON input string here
  24. * and finalize it with `parseToolInput` once the call completes.
  25. */
  26. export interface ToolAccumulator {
  27. readonly id: string
  28. readonly name: string
  29. readonly input: string
  30. }
  31. /**
  32. * `Usage.totalTokens` policy shared by every route. Honors a provider-
  33. * supplied total; otherwise falls back to `inputTokens + outputTokens` only
  34. * when at least one is defined. Returns `undefined` when neither input nor
  35. * output is known so routes don't publish a misleading `0`.
  36. *
  37. * Under the additive `LLM.Usage` contract, `inputTokens` and `outputTokens`
  38. * are the non-cached input and visible output only. The provider-supplied
  39. * `total` is the source of truth when present; the computed fallback
  40. * under-counts cache and reasoning by design and exists mainly so
  41. * Anthropic-style providers (which don't surface a total) still get a
  42. * sensible aggregate on the input + output axes.
  43. */
  44. export const totalTokens = (
  45. inputTokens: number | undefined,
  46. outputTokens: number | undefined,
  47. total: number | undefined,
  48. ) => {
  49. if (total !== undefined) return total
  50. if (inputTokens === undefined && outputTokens === undefined) return undefined
  51. return (inputTokens ?? 0) + (outputTokens ?? 0)
  52. }
  53. /**
  54. * Subtract `subtrahend` from `total`, clamping to zero if the provider
  55. * reports a non-sensical breakdown (e.g. `cached_tokens > prompt_tokens`).
  56. * Used by protocol mappers when deriving a non-overlapping breakdown field
  57. * from a provider's inclusive total — `nonCachedInputTokens` from
  58. * `inputTokens - cacheReadInputTokens - cacheWriteInputTokens`.
  59. *
  60. * If `total` is `undefined`, returns `undefined` (we don't fabricate
  61. * counts). If `subtrahend` is `undefined`, returns `total` unchanged. The
  62. * provider-native breakdown stays available on `Usage.native` for debugging.
  63. */
  64. export const subtractTokens = (total: number | undefined, subtrahend: number | undefined): number | undefined => {
  65. if (total === undefined) return undefined
  66. if (subtrahend === undefined) return total
  67. return Math.max(0, total - subtrahend)
  68. }
  69. /**
  70. * Sum a list of optional token counts, returning `undefined` only when
  71. * every value is `undefined` (so we don't fabricate a `0`). Used by
  72. * protocol mappers to derive the inclusive `inputTokens` total from a
  73. * provider that natively reports a non-overlapping breakdown
  74. * (e.g. Anthropic, whose `input_tokens` is already non-cached only).
  75. */
  76. export const sumTokens = (...values: ReadonlyArray<number | undefined>): number | undefined => {
  77. if (values.every((value) => value === undefined)) return undefined
  78. return values.reduce((acc: number, value) => acc + (value ?? 0), 0)
  79. }
  80. export const eventError = (route: string, message: string, raw?: string) =>
  81. new LLMError({
  82. module: "ProviderShared",
  83. method: "stream",
  84. reason: new InvalidProviderOutputReason({ route, message, raw }),
  85. })
  86. export const parseJson = (route: string, input: string, message: string) =>
  87. Effect.try({
  88. try: () => decodeJson(input),
  89. catch: () => eventError(route, message, input),
  90. })
  91. /**
  92. * Join the `text` field of a list of parts with newlines. Used by routes
  93. * that flatten system / message content arrays into a single provider string
  94. * (OpenAI Chat `system` content, OpenAI Responses `system` content, Gemini
  95. * `systemInstruction.parts[].text`).
  96. */
  97. export const joinText = (parts: ReadonlyArray<{ readonly text: string }>) => parts.map((part) => part.text).join("\n")
  98. /**
  99. * Parse the streamed JSON input of a tool call. Treats an empty string as
  100. * `"{}"` — providers occasionally finish a tool call without ever emitting
  101. * input deltas (e.g. zero-arg tools). The error message is uniform across
  102. * routes: `Invalid JSON input for <route> tool call <name>`.
  103. */
  104. export const parseToolInput = (route: string, name: string, raw: string) =>
  105. parseJson(route, raw || "{}", `Invalid JSON input for ${route} tool call ${name}`)
  106. /**
  107. * Encode a `MediaPart`'s raw bytes for inclusion in a JSON request body.
  108. * `data: string` is assumed to already be base64 (matches caller convention
  109. * across Gemini / Bedrock); `data: Uint8Array` is base64-encoded here. Used
  110. * by every route that supports image / document inputs.
  111. */
  112. export const mediaBytes = (part: MediaPart) =>
  113. typeof part.data === "string" ? part.data : Buffer.from(part.data).toString("base64")
  114. export const mediaBase64 = (part: MediaPart) => {
  115. if (typeof part.data !== "string" || !part.data.startsWith("data:")) return mediaBytes(part)
  116. return part.data.slice(part.data.indexOf(",") + 1)
  117. }
  118. export const mediaDataUrl = (part: MediaPart) =>
  119. typeof part.data === "string" && part.data.startsWith("data:")
  120. ? part.data
  121. : `data:${part.mediaType};base64,${mediaBytes(part)}`
  122. export const trimBaseUrl = (value: string) => value.replace(/\/+$/, "")
  123. export const toolResultText = (part: ToolResultPart) => {
  124. if (part.result.type === "text" || part.result.type === "error") return String(part.result.value)
  125. if (part.result.type === "content") return encodeJson(part.result.value)
  126. return encodeJson(part.result.value)
  127. }
  128. export const errorText = (error: unknown) => {
  129. if (error instanceof Error) return error.message
  130. if (typeof error === "string") return error
  131. if (typeof error === "number" || typeof error === "boolean" || typeof error === "bigint") return String(error)
  132. if (error === null) return "null"
  133. if (error === undefined) return "undefined"
  134. return "Unknown stream error"
  135. }
  136. /**
  137. * `framing` step for Server-Sent Events. Decodes UTF-8, runs the SSE channel
  138. * decoder, and drops empty / `[DONE]` keep-alive events so the downstream
  139. * `decodeChunk` sees one JSON string per element. The SSE channel emits a
  140. * `Retry` control event on its error channel; we drop it here (we don't
  141. * implement client-driven retries) so the public error channel stays
  142. * `LLMError`.
  143. */
  144. export const sseFraming = (bytes: Stream.Stream<Uint8Array, LLMError>): Stream.Stream<string, LLMError> =>
  145. bytes.pipe(
  146. Stream.decodeText(),
  147. Stream.pipeThroughChannel(Sse.decode()),
  148. Stream.catchTag("Retry", () => Stream.empty),
  149. Stream.filter((event) => event.data.length > 0 && event.data !== "[DONE]"),
  150. Stream.map((event) => event.data),
  151. )
  152. /**
  153. * Canonical invalid-request constructor. Lift one-line `const invalid =
  154. * (message) => invalidRequest(message)` aliases out of every
  155. * route so the error constructor lives in one place. If we ever extend
  156. * `InvalidRequestReason` with route context or trace metadata, the change
  157. * lands here.
  158. */
  159. export const invalidRequest = (message: string) =>
  160. new LLMError({
  161. module: "ProviderShared",
  162. method: "request",
  163. reason: new InvalidRequestReason({ message }),
  164. })
  165. export const matchToolChoice = <Auto, None, Required, Tool>(
  166. route: string,
  167. toolChoice: NonNullable<LLMRequest["toolChoice"]>,
  168. cases: {
  169. readonly auto: () => Auto
  170. readonly none: () => None
  171. readonly required: () => Required
  172. readonly tool: (name: string) => Tool
  173. },
  174. ) =>
  175. Effect.gen(function* () {
  176. if (toolChoice.type === "auto") return cases.auto()
  177. if (toolChoice.type === "none") return cases.none()
  178. if (toolChoice.type === "required") return cases.required()
  179. if (!toolChoice.name) return yield* invalidRequest(`${route} tool choice requires a tool name`)
  180. return cases.tool(toolChoice.name)
  181. })
  182. type ContentType = ContentPart["type"]
  183. const formatContentTypes = (types: ReadonlyArray<ContentType>) => {
  184. if (types.length <= 1) return types[0] ?? ""
  185. if (types.length === 2) return `${types[0]} and ${types[1]}`
  186. return `${types.slice(0, -1).join(", ")}, and ${types.at(-1)}`
  187. }
  188. export const supportsContent = <const Type extends ContentType>(
  189. part: ContentPart,
  190. types: ReadonlyArray<Type>,
  191. ): part is Extract<ContentPart, { readonly type: Type }> => (types as ReadonlyArray<ContentType>).includes(part.type)
  192. export const unsupportedContent = (
  193. route: string,
  194. role: LLMRequest["messages"][number]["role"],
  195. types: ReadonlyArray<ContentType>,
  196. ) => invalidRequest(`${route} ${role} messages only support ${formatContentTypes(types)} content for now`)
  197. /**
  198. * Build a `validate` step from a Schema decoder. Replaces the per-route
  199. * lambda body `(payload) => decode(payload).pipe(Effect.mapError((e) =>
  200. * invalid(e.message)))`. Any decode error is translated into
  201. * `LLMError` carrying the original parse-error message.
  202. */
  203. export const validateWith =
  204. <A, I, E extends { readonly message: string }>(decode: (input: I) => Effect.Effect<A, E>) =>
  205. (payload: I) =>
  206. decode(payload).pipe(Effect.mapError((error) => invalidRequest(error.message)))
  207. /**
  208. * Build an HTTP POST with a JSON body. Sets `content-type: application/json`
  209. * automatically after caller-supplied headers so routes cannot accidentally
  210. * send JSON with a stale content type. The body is passed pre-encoded so
  211. * routes can choose between
  212. * `Schema.encodeSync(payload)` and `ProviderShared.encodeJson(payload)`.
  213. */
  214. export const jsonPost = (input: { readonly url: string; readonly body: string; readonly headers?: Headers.Input }) =>
  215. HttpClientRequest.post(input.url).pipe(
  216. HttpClientRequest.setHeaders(Headers.set(Headers.fromInput(input.headers), "content-type", "application/json")),
  217. HttpClientRequest.bodyText(input.body, "application/json"),
  218. )
  219. export * as ProviderShared from "./shared"