catalog.ts 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269
  1. export * as Catalog from "./catalog"
  2. import { Context, Effect, HashMap, Layer, Option, Order, pipe, Schema, Array } from "effect"
  3. import { produce, type Draft } from "immer"
  4. import { ModelV2 } from "./model"
  5. import { PluginV2 } from "./plugin"
  6. import { ProviderV2 } from "./provider"
  7. import { Location } from "./location"
  8. import { EventV2 } from "./event"
  9. type ProviderRecord = {
  10. provider: ProviderV2.Info
  11. models: HashMap.HashMap<ModelV2.ID, ModelV2.Info>
  12. }
  13. export class ProviderNotFoundError extends Schema.TaggedErrorClass<ProviderNotFoundError>()(
  14. "CatalogV2.ProviderNotFound",
  15. {
  16. providerID: ProviderV2.ID,
  17. },
  18. ) {}
  19. export class ModelNotFoundError extends Schema.TaggedErrorClass<ModelNotFoundError>()("CatalogV2.ModelNotFound", {
  20. providerID: ProviderV2.ID,
  21. modelID: ModelV2.ID,
  22. }) {}
  23. export const Event = {
  24. ModelUpdated: EventV2.define({
  25. type: "catalog.model.updated",
  26. schema: {
  27. model: ModelV2.Info,
  28. },
  29. }),
  30. }
  31. export interface Interface {
  32. readonly provider: {
  33. readonly get: (providerID: ProviderV2.ID) => Effect.Effect<ProviderV2.Info, ProviderNotFoundError>
  34. readonly update: (providerID: ProviderV2.ID, fn: (provider: Draft<ProviderV2.Info>) => void) => Effect.Effect<void>
  35. readonly all: () => Effect.Effect<ProviderV2.Info[]>
  36. readonly available: () => Effect.Effect<ProviderV2.Info[]>
  37. }
  38. readonly model: {
  39. readonly get: (
  40. providerID: ProviderV2.ID,
  41. modelID: ModelV2.ID,
  42. ) => Effect.Effect<ModelV2.Info, ProviderNotFoundError | ModelNotFoundError>
  43. readonly update: (
  44. providerID: ProviderV2.ID,
  45. modelID: ModelV2.ID,
  46. fn: (model: Draft<ModelV2.Info>) => void,
  47. ) => Effect.Effect<void, ProviderNotFoundError>
  48. readonly all: () => Effect.Effect<ModelV2.Info[]>
  49. readonly available: () => Effect.Effect<ModelV2.Info[]>
  50. readonly default: () => Effect.Effect<Option.Option<ModelV2.Info>>
  51. readonly setDefault: (
  52. providerID: ProviderV2.ID,
  53. modelID: ModelV2.ID,
  54. ) => Effect.Effect<void, ProviderNotFoundError | ModelNotFoundError>
  55. readonly small: (providerID: ProviderV2.ID) => Effect.Effect<Option.Option<ModelV2.Info>>
  56. }
  57. }
  58. export class Service extends Context.Service<Service, Interface>()("@opencode/v2/Catalog") {}
  59. export const layer = Layer.effect(
  60. Service,
  61. Effect.gen(function* () {
  62. yield* Location.Service
  63. let records = HashMap.empty<ProviderV2.ID, ProviderRecord>()
  64. let defaultModel: { providerID: ProviderV2.ID; modelID: ModelV2.ID } | undefined
  65. const plugin = yield* PluginV2.Service
  66. const events = yield* EventV2.Service
  67. const resolve = (model: ModelV2.Info) => {
  68. const provider = Option.getOrThrow(HashMap.get(records, model.providerID)).provider
  69. const endpoint =
  70. model.endpoint.type === "unknown"
  71. ? provider.endpoint
  72. : model.endpoint.type === "aisdk" && provider.endpoint.type === "aisdk" && !model.endpoint.url
  73. ? { ...model.endpoint, url: provider.endpoint.url }
  74. : model.endpoint
  75. const options = {
  76. headers: {
  77. ...provider.options.headers,
  78. ...model.options.headers,
  79. },
  80. body: {
  81. ...provider.options.body,
  82. ...model.options.body,
  83. },
  84. aisdk: {
  85. provider: {
  86. ...provider.options.aisdk.provider,
  87. ...model.options.aisdk.provider,
  88. },
  89. request: model.options.aisdk.request,
  90. },
  91. variant: model.options.variant,
  92. }
  93. return new ModelV2.Info({
  94. ...model,
  95. endpoint,
  96. options,
  97. })
  98. }
  99. function* getRecord(providerID: ProviderV2.ID) {
  100. const match = HashMap.get(records, providerID)
  101. if (!match.valueOrUndefined) return yield* new ProviderNotFoundError({ providerID })
  102. return match.value
  103. }
  104. const result: Interface = {
  105. provider: {
  106. get: Effect.fn("CatalogV2.provider.get")(function* (providerID) {
  107. const record = yield* getRecord(providerID)
  108. return record.provider
  109. }),
  110. update: Effect.fnUntraced(function* (providerID, fn) {
  111. const current = Option.getOrUndefined(HashMap.get(records, providerID))
  112. const provider = produce(current?.provider ?? ProviderV2.Info.empty(providerID), (draft) => {
  113. fn(draft)
  114. if (draft.endpoint.type === "aisdk" && typeof draft.options.aisdk.provider.baseURL === "string") {
  115. draft.endpoint.url = draft.options.aisdk.provider.baseURL
  116. delete draft.options.aisdk.provider.baseURL
  117. }
  118. })
  119. const updated = yield* plugin.trigger("provider.update", {}, { provider, cancel: false })
  120. records = HashMap.set(records, providerID, {
  121. provider: updated.provider,
  122. models: current?.models ?? HashMap.empty<ModelV2.ID, ModelV2.Info>(),
  123. })
  124. }),
  125. all: Effect.fn("CatalogV2.provider.all")(function* () {
  126. return globalThis.Array.from(HashMap.values(records)).map((record) => record.provider)
  127. }),
  128. available: Effect.fn("CatalogV2.provider.available")(function* () {
  129. return globalThis.Array.from(HashMap.values(records))
  130. .map((record) => record.provider)
  131. .filter((provider) => provider.enabled)
  132. }),
  133. },
  134. model: {
  135. get: Effect.fn("CatalogV2.model.get")(function* (providerID, modelID) {
  136. const record = yield* getRecord(providerID)
  137. const model = Option.getOrUndefined(HashMap.get(record.models, modelID))
  138. if (!model) return yield* new ModelNotFoundError({ providerID, modelID })
  139. return resolve(model)
  140. }),
  141. update: Effect.fnUntraced(function* (providerID, modelID, fn) {
  142. const record = yield* getRecord(providerID)
  143. const model = produce(
  144. HashMap.get(record.models, modelID).pipe(Option.getOrElse(() => ModelV2.Info.empty(providerID, modelID))),
  145. (draft) => {
  146. fn(draft)
  147. if (draft.endpoint.type === "aisdk" && typeof draft.options.aisdk.provider.baseURL === "string") {
  148. draft.endpoint.url = draft.options.aisdk.provider.baseURL
  149. delete draft.options.aisdk.provider.baseURL
  150. }
  151. },
  152. )
  153. const updated = yield* plugin.trigger("model.update", {}, { model, cancel: false })
  154. if (updated.cancel) return
  155. const next = new ModelV2.Info({ ...updated.model, id: modelID, providerID })
  156. records = HashMap.set(records, providerID, {
  157. provider: record.provider,
  158. models: HashMap.set(record.models, modelID, next),
  159. })
  160. yield* events.publish(Event.ModelUpdated, { model: resolve(next) })
  161. return
  162. }),
  163. all: Effect.fn("CatalogV2.model.all")(function* () {
  164. return pipe(
  165. records,
  166. HashMap.toValues,
  167. Array.flatMap((record) => HashMap.toValues(record.models)),
  168. Array.map(resolve),
  169. Array.sortWith((item) => item.time.released.epochMilliseconds, Order.flip(Order.Number)),
  170. )
  171. }),
  172. available: Effect.fn("CatalogV2.model.available")(function* () {
  173. return (yield* result.model.all()).filter((model) => {
  174. const record = Option.getOrUndefined(HashMap.get(records, model.providerID))
  175. return record?.provider.enabled !== false && model.enabled
  176. })
  177. }),
  178. default: Effect.fn("CatalogV2.model.default")(function* () {
  179. if (defaultModel) {
  180. const model = yield* result.model.get(defaultModel.providerID, defaultModel.modelID).pipe(Effect.option)
  181. if (Option.isSome(model) && model.value.enabled) return model
  182. }
  183. return pipe(
  184. yield* result.model.available(),
  185. Array.sortWith((item) => item.time.released.epochMilliseconds, Order.flip(Order.Number)),
  186. Array.head,
  187. )
  188. }),
  189. setDefault: Effect.fn("CatalogV2.model.setDefault")(function* (providerID, modelID) {
  190. yield* result.model.get(providerID, modelID)
  191. defaultModel = { providerID, modelID }
  192. }),
  193. small: Effect.fn("CatalogV2.model.small")(function* (providerID) {
  194. const record = Option.getOrUndefined(HashMap.get(records, providerID))
  195. if (!record) return Option.none<ModelV2.Info>()
  196. if (providerID === ProviderV2.ID.opencode) {
  197. const gpt5Nano = Option.getOrUndefined(HashMap.get(record.models, ModelV2.ID.make("gpt-5-nano")))
  198. if (gpt5Nano?.enabled && gpt5Nano.status === "active") return Option.some(resolve(gpt5Nano))
  199. }
  200. const candidates = pipe(
  201. HashMap.toValues(record.models),
  202. Array.filter(
  203. (model) =>
  204. model.providerID === providerID &&
  205. model.enabled &&
  206. model.status === "active" &&
  207. model.capabilities.input.some((item) => item.startsWith("text")) &&
  208. model.capabilities.output.some((item) => item.startsWith("text")),
  209. ),
  210. Array.map((model) => ({
  211. model,
  212. cost: model.cost[0] ? model.cost[0].input + model.cost[0].output : 999,
  213. age: (Date.now() - model.time.released.epochMilliseconds) / (1000 * 60 * 60 * 24 * 30),
  214. small: SMALL_MODEL_RE.test(`${model.id} ${model.family ?? ""} ${model.name}`.toLowerCase()),
  215. })),
  216. Array.filter((item) => item.cost > 0 && item.age <= 18),
  217. )
  218. const pick = (items: typeof candidates) => {
  219. const maxCost = Math.max(...items.map((item) => item.cost), 0.01)
  220. const maxAge = Math.max(...items.map((item) => item.age), 0.01)
  221. return pipe(
  222. items,
  223. Array.sortWith((item) => (item.cost / maxCost) * 0.8 + (item.age / maxAge) * 0.2, Order.Number),
  224. Array.map((item) => resolve(item.model)),
  225. Array.head,
  226. )
  227. }
  228. return pipe(
  229. candidates,
  230. Array.filter((item) => item.small),
  231. (items) => (items.length > 0 ? pick(items) : pick(candidates)),
  232. )
  233. }),
  234. },
  235. }
  236. return Service.of(result)
  237. }),
  238. )
  239. const SMALL_MODEL_RE = /\b(nano|flash|lite|mini|haiku|small|fast)\b/
  240. export const defaultLayer = layer.pipe(Layer.provideMerge(EventV2.defaultLayer), Layer.provide(PluginV2.defaultLayer))