options.ts 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276
  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 ModelDefaults extends Schema.Class<ModelDefaults>("LLM.ModelDefaults")({
  119. limits: Schema.optional(ModelLimits),
  120. generation: Schema.optional(GenerationOptions),
  121. providerOptions: Schema.optional(ProviderOptions),
  122. http: Schema.optional(HttpOptions),
  123. }) {}
  124. export namespace ModelDefaults {
  125. export type Input =
  126. | ModelDefaults
  127. | {
  128. readonly limits?: ModelLimits.Input
  129. readonly generation?: GenerationOptions.Input
  130. readonly providerOptions?: ProviderOptions
  131. readonly http?: HttpOptions.Input
  132. }
  133. /** Normalize selected-model request defaults without applying precedence. */
  134. export const make = (input: Input) => {
  135. if (input instanceof ModelDefaults) return input
  136. return new ModelDefaults({
  137. limits: input.limits === undefined ? undefined : ModelLimits.make(input.limits),
  138. generation: input.generation === undefined ? undefined : GenerationOptions.make(input.generation),
  139. providerOptions: input.providerOptions,
  140. http: input.http === undefined ? undefined : HttpOptions.make(input.http),
  141. })
  142. }
  143. }
  144. export const ModelToolSchemaCompatibility = Schema.Literals(["gemini", "moonshot"])
  145. export type ModelToolSchemaCompatibility = Schema.Schema.Type<typeof ModelToolSchemaCompatibility>
  146. export class ModelCompatibility extends Schema.Class<ModelCompatibility>("LLM.ModelCompatibility")({
  147. toolSchema: Schema.optional(ModelToolSchemaCompatibility),
  148. }) {}
  149. export namespace ModelCompatibility {
  150. export type Input = ModelCompatibility | ConstructorParameters<typeof ModelCompatibility>[0]
  151. /** Normalize model/upstream compatibility metadata without projecting requests. */
  152. export const make = (input: Input) => (input instanceof ModelCompatibility ? input : new ModelCompatibility(input))
  153. }
  154. export class Model {
  155. readonly id: ModelID
  156. readonly provider: ProviderID
  157. readonly route: AnyRoute
  158. readonly defaults?: ModelDefaults
  159. readonly compatibility?: ModelCompatibility
  160. constructor(input: Model.ConstructorInput) {
  161. this.id = input.id
  162. this.provider = input.provider
  163. this.route = input.route
  164. this.defaults = input.defaults
  165. this.compatibility = input.compatibility
  166. }
  167. static make(input: Model.Input) {
  168. return new Model({
  169. id: ModelID.make(input.id),
  170. provider: ProviderID.make(input.provider),
  171. route: input.route,
  172. defaults: input.defaults === undefined ? undefined : ModelDefaults.make(input.defaults),
  173. compatibility: input.compatibility === undefined ? undefined : ModelCompatibility.make(input.compatibility),
  174. })
  175. }
  176. static input(model: Model): Model.ConstructorInput {
  177. return {
  178. id: model.id,
  179. provider: model.provider,
  180. route: model.route,
  181. defaults: model.defaults,
  182. compatibility: model.compatibility,
  183. }
  184. }
  185. static update(model: Model, patch: Partial<Model.Input>) {
  186. if (Object.keys(patch).length === 0) return model
  187. return Model.make({
  188. ...Model.input(model),
  189. ...patch,
  190. })
  191. }
  192. }
  193. export namespace Model {
  194. export type ConstructorInput = {
  195. readonly id: ModelID
  196. readonly provider: ProviderID
  197. readonly route: AnyRoute
  198. readonly defaults?: ModelDefaults
  199. readonly compatibility?: ModelCompatibility
  200. }
  201. export type Input = Omit<ConstructorInput, "id" | "provider" | "defaults" | "compatibility"> & {
  202. readonly id: string | ModelID
  203. readonly provider: string | ProviderID
  204. readonly defaults?: ModelDefaults.Input
  205. readonly compatibility?: ModelCompatibility.Input
  206. }
  207. }
  208. export type ModelInput = Model.Input
  209. export const ModelSchema = Schema.declare((value): value is Model => value instanceof Model, { expected: "LLM.Model" })
  210. export class CacheHint extends Schema.Class<CacheHint>("LLM.CacheHint")({
  211. type: Schema.Literals(["ephemeral", "persistent"]),
  212. ttlSeconds: Schema.optional(Schema.Number),
  213. }) {}
  214. // Auto-placement policy for prompt caching. The protocol-neutral lowering step
  215. // reads this and injects `CacheHint`s at the configured boundaries; the
  216. // per-protocol body builders then translate those hints into wire markers as
  217. // usual. `"auto"` is the recommended default for agent loops — it places one
  218. // breakpoint at the last tool definition, one at the last system part, and one
  219. // at the latest user message. The combination of provider invalidation
  220. // hierarchy (tools → system → messages) and Anthropic/Bedrock's 20-block
  221. // lookback means three trailing breakpoints reliably cover the static prefix.
  222. //
  223. // Pass `"none"` to opt out entirely (the legacy behavior). Pass the granular
  224. // object form to override individual choices.
  225. export const CachePolicyObject = Schema.Struct({
  226. tools: Schema.optional(Schema.Boolean),
  227. system: Schema.optional(Schema.Boolean),
  228. messages: Schema.optional(
  229. Schema.Union([
  230. Schema.Literal("latest-user-message"),
  231. Schema.Literal("latest-assistant"),
  232. Schema.Struct({ tail: Schema.Number }),
  233. ]),
  234. ),
  235. ttlSeconds: Schema.optional(Schema.Number),
  236. })
  237. export type CachePolicyObject = Schema.Schema.Type<typeof CachePolicyObject>
  238. export const CachePolicy = Schema.Union([Schema.Literal("auto"), Schema.Literal("none"), CachePolicyObject])
  239. export type CachePolicy = Schema.Schema.Type<typeof CachePolicy>