options.ts 8.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221
  1. import { Schema } from "effect"
  2. import { JsonSchema, ModelID, ProviderID } from "./ids"
  3. import type { AnyRoute } from "../route/client"
  4. import { isRecord } from "../utils/record"
  5. export const mergeJsonRecords = (
  6. ...items: ReadonlyArray<Record<string, unknown> | undefined>
  7. ): Record<string, unknown> | undefined => {
  8. const defined = items.filter((item): item is Record<string, unknown> => item !== undefined)
  9. if (defined.length === 0) return undefined
  10. if (defined.length === 1 && Object.values(defined[0]).every((value) => value !== undefined)) return defined[0]
  11. const result: Record<string, unknown> = {}
  12. for (const item of defined) {
  13. for (const [key, value] of Object.entries(item)) {
  14. if (value === undefined) continue
  15. result[key] = isRecord(result[key]) && isRecord(value) ? mergeJsonRecords(result[key], value) : value
  16. }
  17. }
  18. return Object.keys(result).length === 0 ? undefined : result
  19. }
  20. const mergeStringRecords = (
  21. ...items: ReadonlyArray<Record<string, string> | undefined>
  22. ): Record<string, string> | undefined => {
  23. const defined = items.filter((item): item is Record<string, string> => item !== undefined)
  24. if (defined.length === 0) return undefined
  25. if (defined.length === 1) return defined[0]
  26. const result = Object.fromEntries(
  27. defined.flatMap((item) =>
  28. Object.entries(item).filter((entry): entry is [string, string] => entry[1] !== undefined),
  29. ),
  30. )
  31. return Object.keys(result).length === 0 ? undefined : result
  32. }
  33. export const ProviderOptions = Schema.Record(Schema.String, Schema.Record(Schema.String, Schema.Unknown))
  34. export type ProviderOptions = Schema.Schema.Type<typeof ProviderOptions>
  35. export const mergeProviderOptions = (
  36. ...items: ReadonlyArray<ProviderOptions | undefined>
  37. ): ProviderOptions | undefined => {
  38. const result: Record<string, Record<string, unknown>> = {}
  39. for (const item of items) {
  40. if (!item) continue
  41. for (const [provider, options] of Object.entries(item)) {
  42. const merged = mergeJsonRecords(result[provider], options)
  43. if (merged) result[provider] = merged
  44. }
  45. }
  46. return Object.keys(result).length === 0 ? undefined : result
  47. }
  48. export class HttpOptions extends Schema.Class<HttpOptions>("LLM.HttpOptions")({
  49. body: Schema.optional(JsonSchema),
  50. headers: Schema.optional(Schema.Record(Schema.String, Schema.String)),
  51. query: Schema.optional(Schema.Record(Schema.String, Schema.String)),
  52. }) {}
  53. export namespace HttpOptions {
  54. export type Input = HttpOptions | ConstructorParameters<typeof HttpOptions>[0]
  55. /** Normalize HTTP option input into the canonical `HttpOptions` class. */
  56. export const make = (input: Input) => (input instanceof HttpOptions ? input : new HttpOptions(input))
  57. }
  58. export const mergeHttpOptions = (...items: ReadonlyArray<HttpOptions | undefined>): HttpOptions | undefined => {
  59. const body = mergeJsonRecords(...items.map((item) => item?.body))
  60. const headers = mergeStringRecords(...items.map((item) => item?.headers))
  61. const query = mergeStringRecords(...items.map((item) => item?.query))
  62. if (!body && !headers && !query) return undefined
  63. return new HttpOptions({ body, headers, query })
  64. }
  65. export class GenerationOptions extends Schema.Class<GenerationOptions>("LLM.GenerationOptions")({
  66. maxTokens: Schema.optional(Schema.Number),
  67. temperature: Schema.optional(Schema.Number),
  68. topP: Schema.optional(Schema.Number),
  69. topK: Schema.optional(Schema.Number),
  70. frequencyPenalty: Schema.optional(Schema.Number),
  71. presencePenalty: Schema.optional(Schema.Number),
  72. seed: Schema.optional(Schema.Number),
  73. stop: Schema.optional(Schema.Array(Schema.String)),
  74. }) {}
  75. export namespace GenerationOptions {
  76. export type Input = GenerationOptions | ConstructorParameters<typeof GenerationOptions>[0]
  77. /** Normalize generation option input into the canonical `GenerationOptions` class. */
  78. export const make = (input: Input = {}) => (input instanceof GenerationOptions ? input : new GenerationOptions(input))
  79. }
  80. export type GenerationOptionsFields = {
  81. readonly maxTokens?: number
  82. readonly temperature?: number
  83. readonly topP?: number
  84. readonly topK?: number
  85. readonly frequencyPenalty?: number
  86. readonly presencePenalty?: number
  87. readonly seed?: number
  88. readonly stop?: ReadonlyArray<string>
  89. }
  90. export type GenerationOptionsInput = GenerationOptions | GenerationOptionsFields
  91. const latestGeneration = <Key extends keyof GenerationOptionsFields>(
  92. items: ReadonlyArray<GenerationOptionsInput | undefined>,
  93. key: Key,
  94. ) => items.findLast((item) => item?.[key] !== undefined)?.[key]
  95. export const mergeGenerationOptions = (...items: ReadonlyArray<GenerationOptionsInput | undefined>) => {
  96. const result = new GenerationOptions({
  97. maxTokens: latestGeneration(items, "maxTokens"),
  98. temperature: latestGeneration(items, "temperature"),
  99. topP: latestGeneration(items, "topP"),
  100. topK: latestGeneration(items, "topK"),
  101. frequencyPenalty: latestGeneration(items, "frequencyPenalty"),
  102. presencePenalty: latestGeneration(items, "presencePenalty"),
  103. seed: latestGeneration(items, "seed"),
  104. stop: latestGeneration(items, "stop"),
  105. })
  106. return Object.values(result).some((value) => value !== undefined) ? result : undefined
  107. }
  108. export class ModelLimits extends Schema.Class<ModelLimits>("LLM.ModelLimits")({
  109. context: Schema.optional(Schema.Number),
  110. output: Schema.optional(Schema.Number),
  111. }) {}
  112. export namespace ModelLimits {
  113. export type Input = ModelLimits | ConstructorParameters<typeof ModelLimits>[0]
  114. /** Normalize model limit input into the canonical `ModelLimits` class. */
  115. export const make = (input: Input | undefined) =>
  116. input instanceof ModelLimits ? input : new ModelLimits(input ?? {})
  117. }
  118. export class Model {
  119. readonly id: ModelID
  120. readonly provider: ProviderID
  121. readonly route: AnyRoute
  122. constructor(input: Model.ConstructorInput) {
  123. this.id = input.id
  124. this.provider = input.provider
  125. this.route = input.route
  126. }
  127. static make(input: Model.Input) {
  128. return new Model({
  129. id: ModelID.make(input.id),
  130. provider: ProviderID.make(input.provider),
  131. route: input.route,
  132. })
  133. }
  134. static input(model: Model): Model.ConstructorInput {
  135. return {
  136. id: model.id,
  137. provider: model.provider,
  138. route: model.route,
  139. }
  140. }
  141. static update(model: Model, patch: Partial<Model.Input>) {
  142. if (Object.keys(patch).length === 0) return model
  143. return Model.make({
  144. ...Model.input(model),
  145. ...patch,
  146. })
  147. }
  148. }
  149. export namespace Model {
  150. export type ConstructorInput = {
  151. readonly id: ModelID
  152. readonly provider: ProviderID
  153. readonly route: AnyRoute
  154. }
  155. export type Input = Omit<ConstructorInput, "id" | "provider"> & {
  156. readonly id: string | ModelID
  157. readonly provider: string | ProviderID
  158. }
  159. }
  160. export type ModelInput = Model.Input
  161. export const ModelSchema = Schema.declare((value): value is Model => value instanceof Model, { expected: "LLM.Model" })
  162. export class CacheHint extends Schema.Class<CacheHint>("LLM.CacheHint")({
  163. type: Schema.Literals(["ephemeral", "persistent"]),
  164. ttlSeconds: Schema.optional(Schema.Number),
  165. }) {}
  166. // Auto-placement policy for prompt caching. The protocol-neutral lowering step
  167. // reads this and injects `CacheHint`s at the configured boundaries; the
  168. // per-protocol body builders then translate those hints into wire markers as
  169. // usual. `"auto"` is the recommended default for agent loops — it places one
  170. // breakpoint at the last tool definition, one at the last system part, and one
  171. // at the latest user message. The combination of provider invalidation
  172. // hierarchy (tools → system → messages) and Anthropic/Bedrock's 20-block
  173. // lookback means three trailing breakpoints reliably cover the static prefix.
  174. //
  175. // Pass `"none"` to opt out entirely (the legacy behavior). Pass the granular
  176. // object form to override individual choices.
  177. export const CachePolicyObject = Schema.Struct({
  178. tools: Schema.optional(Schema.Boolean),
  179. system: Schema.optional(Schema.Boolean),
  180. messages: Schema.optional(
  181. Schema.Union([
  182. Schema.Literal("latest-user-message"),
  183. Schema.Literal("latest-assistant"),
  184. Schema.Struct({ tail: Schema.Number }),
  185. ]),
  186. ),
  187. ttlSeconds: Schema.optional(Schema.Number),
  188. })
  189. export type CachePolicyObject = Schema.Schema.Type<typeof CachePolicyObject>
  190. export const CachePolicy = Schema.Union([Schema.Literal("auto"), Schema.Literal("none"), CachePolicyObject])
  191. export type CachePolicy = Schema.Schema.Type<typeof CachePolicy>