catalog.ts 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357
  1. export * as Catalog from "./catalog"
  2. import { Context, Effect, HashMap, Layer, Option, Order, pipe, Schema, Array, Scope, Stream } 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. export type ProviderRecord = {
  10. provider: ProviderV2.Info
  11. models: Map<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 type Context = {
  32. data: readonly ProviderRecord[]
  33. updateProvider: (providerID: ProviderV2.ID, fn: (provider: Draft<ProviderV2.Info>) => void) => void
  34. updateModel: (providerID: ProviderV2.ID, modelID: ModelV2.ID, fn: (model: Draft<ModelV2.Info>) => void) => void
  35. provider: {
  36. update: (providerID: ProviderV2.ID, fn: (provider: Draft<ProviderV2.Info>) => void) => void
  37. remove: (providerID: ProviderV2.ID) => void
  38. }
  39. model: {
  40. update: (providerID: ProviderV2.ID, modelID: ModelV2.ID, fn: (model: Draft<ModelV2.Info>) => void) => void
  41. remove: (providerID: ProviderV2.ID, modelID: ModelV2.ID) => void
  42. }
  43. }
  44. export type Loader = (update: (ctx: Context) => void) => Effect.Effect<void>
  45. export interface Interface {
  46. readonly loader: () => Effect.Effect<Loader, never, Scope.Scope>
  47. readonly provider: {
  48. readonly get: (providerID: ProviderV2.ID) => Effect.Effect<ProviderV2.Info, ProviderNotFoundError>
  49. readonly all: () => Effect.Effect<ProviderV2.Info[]>
  50. readonly available: () => Effect.Effect<ProviderV2.Info[]>
  51. }
  52. readonly model: {
  53. readonly get: (
  54. providerID: ProviderV2.ID,
  55. modelID: ModelV2.ID,
  56. ) => Effect.Effect<ModelV2.Info, ProviderNotFoundError | ModelNotFoundError>
  57. readonly all: () => Effect.Effect<ModelV2.Info[]>
  58. readonly available: () => Effect.Effect<ModelV2.Info[]>
  59. readonly default: () => Effect.Effect<Option.Option<ModelV2.Info>>
  60. readonly setDefault: (
  61. providerID: ProviderV2.ID,
  62. modelID: ModelV2.ID,
  63. ) => Effect.Effect<void, ProviderNotFoundError | ModelNotFoundError>
  64. readonly small: (providerID: ProviderV2.ID) => Effect.Effect<Option.Option<ModelV2.Info>>
  65. }
  66. }
  67. export class Service extends Context.Service<Service, Interface>()("@opencode/v2/Catalog") {}
  68. export const layer = Layer.effect(
  69. Service,
  70. Effect.gen(function* () {
  71. yield* Location.Service
  72. let records = HashMap.empty<ProviderV2.ID, ProviderRecord>()
  73. let loaders: { update: (ctx: Context) => void }[] = []
  74. let defaultModel: { providerID: ProviderV2.ID; modelID: ModelV2.ID } | undefined
  75. const plugin = yield* PluginV2.Service
  76. const events = yield* EventV2.Service
  77. const scope = yield* Scope.Scope
  78. const resolve = (model: ModelV2.Info) => {
  79. const provider = Option.getOrThrow(HashMap.get(records, model.providerID)).provider
  80. const endpoint =
  81. model.endpoint.type === "unknown"
  82. ? provider.endpoint
  83. : model.endpoint.type === "aisdk" && provider.endpoint.type === "aisdk" && !model.endpoint.url
  84. ? { ...model.endpoint, url: provider.endpoint.url }
  85. : model.endpoint
  86. const options = {
  87. headers: {
  88. ...provider.options.headers,
  89. ...model.options.headers,
  90. },
  91. body: {
  92. ...provider.options.body,
  93. ...model.options.body,
  94. },
  95. aisdk: {
  96. provider: {
  97. ...provider.options.aisdk.provider,
  98. ...model.options.aisdk.provider,
  99. },
  100. request: model.options.aisdk.request,
  101. },
  102. variant: model.options.variant,
  103. }
  104. return new ModelV2.Info({
  105. ...model,
  106. endpoint,
  107. options,
  108. })
  109. }
  110. function* getRecord(providerID: ProviderV2.ID) {
  111. const match = HashMap.get(records, providerID)
  112. if (!match.valueOrUndefined) return yield* new ProviderNotFoundError({ providerID })
  113. return match.value
  114. }
  115. const normalizeEndpoint = (item: Draft<ProviderV2.Info> | Draft<ModelV2.Info>) => {
  116. if (item.endpoint.type !== "aisdk" || typeof item.options.aisdk.provider.baseURL !== "string") return
  117. item.endpoint.url = item.options.aisdk.provider.baseURL
  118. delete item.options.aisdk.provider.baseURL
  119. }
  120. const clone = (input: HashMap.HashMap<ProviderV2.ID, ProviderRecord>) =>
  121. HashMap.fromIterable(
  122. HashMap.toEntries(input).map(([key, value]) => [key, { ...value, models: new Map(value.models) }] as const),
  123. )
  124. const context = (draft: {
  125. records: HashMap.HashMap<ProviderV2.ID, ProviderRecord>
  126. data: ProviderRecord[]
  127. }): Context => {
  128. const result: Context = {
  129. data: draft.data,
  130. updateProvider: (providerID, fn) => result.provider.update(providerID, fn),
  131. updateModel: (providerID, modelID, fn) => result.model.update(providerID, modelID, fn),
  132. provider: {
  133. update: (providerID, fn) => {
  134. const current = Option.getOrUndefined(HashMap.get(draft.records, providerID))
  135. const provider = produce(current?.provider ?? ProviderV2.Info.empty(providerID), (draft) => {
  136. fn(draft)
  137. normalizeEndpoint(draft)
  138. })
  139. const next = {
  140. provider,
  141. models: current?.models ?? new Map<ModelV2.ID, ModelV2.Info>(),
  142. }
  143. draft.records = HashMap.set(draft.records, providerID, next)
  144. const index = draft.data.findIndex((item) => item.provider.id === providerID)
  145. if (index === -1) draft.data.push(next)
  146. else draft.data[index] = next
  147. },
  148. remove: (providerID) => {
  149. draft.records = HashMap.remove(draft.records, providerID)
  150. const index = draft.data.findIndex((item) => item.provider.id === providerID)
  151. if (index !== -1) draft.data.splice(index, 1)
  152. },
  153. },
  154. model: {
  155. update: (providerID, modelID, fn) => {
  156. const current = Option.getOrThrow(HashMap.get(draft.records, providerID))
  157. const model = produce(current.models.get(modelID) ?? ModelV2.Info.empty(providerID, modelID), (draft) => {
  158. fn(draft)
  159. normalizeEndpoint(draft)
  160. })
  161. const next = {
  162. provider: current.provider,
  163. models: new Map(current.models).set(modelID, new ModelV2.Info({ ...model, id: modelID, providerID })),
  164. }
  165. draft.records = HashMap.set(draft.records, providerID, next)
  166. const index = draft.data.findIndex((item) => item.provider.id === providerID)
  167. if (index === -1) draft.data.push(next)
  168. else draft.data[index] = next
  169. },
  170. remove: (providerID, modelID) => {
  171. const current = Option.getOrUndefined(HashMap.get(draft.records, providerID))
  172. if (!current) return
  173. const next = {
  174. provider: current.provider,
  175. models: new Map(current.models),
  176. }
  177. next.models.delete(modelID)
  178. draft.records = HashMap.set(draft.records, providerID, next)
  179. const index = draft.data.findIndex((item) => item.provider.id === providerID)
  180. if (index !== -1) draft.data[index] = next
  181. },
  182. },
  183. }
  184. return result
  185. }
  186. const transform = Effect.fn("CatalogV2.transform")(function* () {
  187. const draft = { records: clone(records), data: HashMap.toValues(records) }
  188. yield* plugin.trigger("catalog.transform", context(draft), {})
  189. records = draft.records
  190. })
  191. const rebuild = Effect.fn("CatalogV2.rebuild")(function* () {
  192. const draft = { records: HashMap.empty<ProviderV2.ID, ProviderRecord>(), data: [] as ProviderRecord[] }
  193. for (const loader of loaders) loader.update(context(draft))
  194. yield* plugin.trigger("catalog.transform", context(draft), {})
  195. records = draft.records
  196. })
  197. yield* plugin.added().pipe(
  198. Stream.runForEach((id) =>
  199. Effect.gen(function* () {
  200. const draft = { records: clone(records), data: HashMap.toValues(records) }
  201. yield* plugin.triggerFor(id, "catalog.transform", context(draft), {})
  202. records = draft.records
  203. }),
  204. ),
  205. Effect.forkIn(scope, { startImmediately: true }),
  206. )
  207. const result: Interface = {
  208. loader: Effect.fn("CatalogV2.loader")(function* () {
  209. const loader = { update: (_ctx: Context) => {} }
  210. loaders = [...loaders, loader]
  211. const scope = yield* Scope.Scope
  212. yield* Scope.addFinalizer(
  213. scope,
  214. Effect.sync(() => {
  215. loaders = loaders.filter((item) => item !== loader)
  216. }).pipe(Effect.andThen(rebuild())),
  217. )
  218. return Effect.fnUntraced(function* (update) {
  219. loader.update = update
  220. yield* rebuild()
  221. })
  222. }),
  223. provider: {
  224. get: Effect.fn("CatalogV2.provider.get")(function* (providerID) {
  225. const record = yield* getRecord(providerID)
  226. return record.provider
  227. }),
  228. all: Effect.fn("CatalogV2.provider.all")(function* () {
  229. return globalThis.Array.from(HashMap.values(records)).map((record) => record.provider)
  230. }),
  231. available: Effect.fn("CatalogV2.provider.available")(function* () {
  232. return globalThis.Array.from(HashMap.values(records))
  233. .map((record) => record.provider)
  234. .filter((provider) => provider.enabled)
  235. }),
  236. },
  237. model: {
  238. get: Effect.fn("CatalogV2.model.get")(function* (providerID, modelID) {
  239. const record = yield* getRecord(providerID)
  240. const model = record.models.get(modelID)
  241. if (!model) return yield* new ModelNotFoundError({ providerID, modelID })
  242. return resolve(model)
  243. }),
  244. all: Effect.fn("CatalogV2.model.all")(function* () {
  245. return pipe(
  246. records,
  247. HashMap.toValues,
  248. Array.flatMap((record) => globalThis.Array.from(record.models.values())),
  249. Array.map(resolve),
  250. Array.sortWith((item) => item.time.released.epochMilliseconds, Order.flip(Order.Number)),
  251. )
  252. }),
  253. available: Effect.fn("CatalogV2.model.available")(function* () {
  254. return (yield* result.model.all()).filter((model) => {
  255. const record = Option.getOrUndefined(HashMap.get(records, model.providerID))
  256. return record?.provider.enabled !== false && model.enabled
  257. })
  258. }),
  259. default: Effect.fn("CatalogV2.model.default")(function* () {
  260. if (defaultModel) {
  261. const model = yield* result.model.get(defaultModel.providerID, defaultModel.modelID).pipe(Effect.option)
  262. if (Option.isSome(model) && model.value.enabled) return model
  263. }
  264. return pipe(
  265. yield* result.model.available(),
  266. Array.sortWith((item) => item.time.released.epochMilliseconds, Order.flip(Order.Number)),
  267. Array.head,
  268. )
  269. }),
  270. setDefault: Effect.fn("CatalogV2.model.setDefault")(function* (providerID, modelID) {
  271. yield* result.model.get(providerID, modelID)
  272. defaultModel = { providerID, modelID }
  273. }),
  274. small: Effect.fn("CatalogV2.model.small")(function* (providerID) {
  275. const record = Option.getOrUndefined(HashMap.get(records, providerID))
  276. if (!record) return Option.none<ModelV2.Info>()
  277. if (providerID === ProviderV2.ID.opencode) {
  278. const gpt5Nano = record.models.get(ModelV2.ID.make("gpt-5-nano"))
  279. if (gpt5Nano?.enabled && gpt5Nano.status === "active") return Option.some(resolve(gpt5Nano))
  280. }
  281. const candidates = pipe(
  282. globalThis.Array.from(record.models.values()),
  283. Array.filter(
  284. (model) =>
  285. model.providerID === providerID &&
  286. model.enabled &&
  287. model.status === "active" &&
  288. model.capabilities.input.some((item) => item.startsWith("text")) &&
  289. model.capabilities.output.some((item) => item.startsWith("text")),
  290. ),
  291. Array.map((model) => ({
  292. model,
  293. cost: model.cost[0] ? model.cost[0].input + model.cost[0].output : 999,
  294. age: (Date.now() - model.time.released.epochMilliseconds) / (1000 * 60 * 60 * 24 * 30),
  295. small: SMALL_MODEL_RE.test(`${model.id} ${model.family ?? ""} ${model.name}`.toLowerCase()),
  296. })),
  297. Array.filter((item) => item.cost > 0 && item.age <= 18),
  298. )
  299. const pick = (items: typeof candidates) => {
  300. const maxCost = Math.max(...items.map((item) => item.cost), 0.01)
  301. const maxAge = Math.max(...items.map((item) => item.age), 0.01)
  302. return pipe(
  303. items,
  304. Array.sortWith((item) => (item.cost / maxCost) * 0.8 + (item.age / maxAge) * 0.2, Order.Number),
  305. Array.map((item) => resolve(item.model)),
  306. Array.head,
  307. )
  308. }
  309. return pipe(
  310. candidates,
  311. Array.filter((item) => item.small),
  312. (items) => (items.length > 0 ? pick(items) : pick(candidates)),
  313. )
  314. }),
  315. },
  316. }
  317. return Service.of(result)
  318. }),
  319. )
  320. const SMALL_MODEL_RE = /\b(nano|flash|lite|mini|haiku|small|fast)\b/
  321. export const defaultLayer = layer.pipe(Layer.provide(EventV2.defaultLayer), Layer.provide(PluginV2.defaultLayer))