catalog.test.ts 7.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201
  1. import { describe, expect } from "bun:test"
  2. import { DateTime, Effect, Layer, Option } from "effect"
  3. import { Catalog } from "@opencode-ai/core/catalog"
  4. import { Instance } from "@opencode-ai/core/instance"
  5. import { ModelV2 } from "@opencode-ai/core/model"
  6. import { PluginV2 } from "@opencode-ai/core/plugin"
  7. import { ProviderV2 } from "@opencode-ai/core/provider"
  8. import { testEffect } from "./lib/effect"
  9. const instanceLayer = Layer.succeed(Instance.Service, Instance.Service.of({ directory: "test" }))
  10. const it = testEffect(Catalog.layer.pipe(Layer.provideMerge(PluginV2.defaultLayer), Layer.provide(instanceLayer)))
  11. describe("CatalogV2", () => {
  12. it.effect("normalizes provider baseURL into endpoint url", () =>
  13. Effect.gen(function* () {
  14. const catalog = yield* Catalog.Service
  15. const providerID = ProviderV2.ID.make("test")
  16. yield* catalog.provider.update(providerID, (provider) => {
  17. provider.endpoint = {
  18. type: "aisdk",
  19. package: "@ai-sdk/openai-compatible",
  20. url: "https://default.example.com",
  21. }
  22. provider.options.aisdk.provider.baseURL = "https://override.example.com"
  23. })
  24. const provider = yield* catalog.provider.get(providerID)
  25. expect(provider.endpoint).toEqual({
  26. type: "aisdk",
  27. package: "@ai-sdk/openai-compatible",
  28. url: "https://override.example.com",
  29. })
  30. expect(provider.options.aisdk.provider.baseURL).toBeUndefined()
  31. }),
  32. )
  33. it.effect("normalizes model baseURL into endpoint url", () =>
  34. Effect.gen(function* () {
  35. const catalog = yield* Catalog.Service
  36. const providerID = ProviderV2.ID.make("test")
  37. const modelID = ModelV2.ID.make("model")
  38. yield* catalog.provider.update(providerID, (provider) => {
  39. provider.endpoint = {
  40. type: "aisdk",
  41. package: "@ai-sdk/openai-compatible",
  42. url: "https://provider.example.com",
  43. }
  44. })
  45. yield* catalog.model.update(providerID, modelID, (model) => {
  46. model.endpoint = {
  47. type: "aisdk",
  48. package: "@ai-sdk/openai-compatible",
  49. url: "https://model.example.com",
  50. }
  51. model.options.aisdk.provider.baseURL = "https://override.example.com"
  52. })
  53. const model = yield* catalog.model.get(providerID, modelID)
  54. expect(model.endpoint).toEqual({
  55. type: "aisdk",
  56. package: "@ai-sdk/openai-compatible",
  57. url: "https://override.example.com",
  58. })
  59. expect(model.options.aisdk.provider.baseURL).toBeUndefined()
  60. }),
  61. )
  62. it.effect("resolves unknown model endpoint from provider endpoint", () =>
  63. Effect.gen(function* () {
  64. const catalog = yield* Catalog.Service
  65. const providerID = ProviderV2.ID.make("test")
  66. const modelID = ModelV2.ID.make("model")
  67. yield* catalog.provider.update(providerID, (provider) => {
  68. provider.endpoint = {
  69. type: "aisdk",
  70. package: "@ai-sdk/openai-compatible",
  71. url: "https://provider.example.com",
  72. }
  73. })
  74. yield* catalog.model.update(providerID, modelID, () => {})
  75. const model = yield* catalog.model.get(providerID, modelID)
  76. expect(model.endpoint).toEqual({
  77. type: "aisdk",
  78. package: "@ai-sdk/openai-compatible",
  79. url: "https://provider.example.com",
  80. })
  81. }),
  82. )
  83. it.effect("runs provider hooks after baseURL is normalized", () =>
  84. Effect.gen(function* () {
  85. const catalog = yield* Catalog.Service
  86. const plugin = yield* PluginV2.Service
  87. const providerID = ProviderV2.ID.make("test")
  88. const seen: unknown[] = []
  89. yield* plugin.add({
  90. id: PluginV2.ID.make("test"),
  91. effect: Effect.succeed({
  92. "provider.update": (evt) =>
  93. Effect.sync(() => {
  94. seen.push(evt.provider.endpoint.type)
  95. if (evt.provider.endpoint.type === "aisdk") seen.push(evt.provider.endpoint.url)
  96. seen.push(evt.provider.options.aisdk.provider.baseURL)
  97. }),
  98. }),
  99. })
  100. yield* catalog.provider.update(providerID, (provider) => {
  101. provider.endpoint = {
  102. type: "aisdk",
  103. package: "@ai-sdk/openai-compatible",
  104. }
  105. provider.options.aisdk.provider.baseURL = "https://provider.example.com"
  106. })
  107. expect(seen).toEqual(["aisdk", "https://provider.example.com", undefined])
  108. }),
  109. )
  110. it.effect("resolves provider and model option merges", () =>
  111. Effect.gen(function* () {
  112. const catalog = yield* Catalog.Service
  113. const providerID = ProviderV2.ID.make("test")
  114. const modelID = ModelV2.ID.make("model")
  115. yield* catalog.provider.update(providerID, (provider) => {
  116. provider.options.headers.provider = "provider"
  117. provider.options.headers.shared = "provider"
  118. provider.options.body.provider = true
  119. provider.options.aisdk.provider.provider = true
  120. })
  121. yield* catalog.model.update(providerID, modelID, (model) => {
  122. model.options.headers.model = "model"
  123. model.options.headers.shared = "model"
  124. model.options.body.model = true
  125. model.options.aisdk.provider.model = true
  126. model.options.aisdk.request.request = true
  127. })
  128. const model = yield* catalog.model.get(providerID, modelID)
  129. expect(model.options.headers).toEqual({ provider: "provider", shared: "model", model: "model" })
  130. expect(model.options.body).toEqual({ provider: true, model: true })
  131. expect(model.options.aisdk.provider).toEqual({ provider: true, model: true })
  132. expect(model.options.aisdk.request).toEqual({ request: true })
  133. }),
  134. )
  135. it.effect("falls back to newest available model when no default is configured", () =>
  136. Effect.gen(function* () {
  137. const catalog = yield* Catalog.Service
  138. const providerID = ProviderV2.ID.make("test")
  139. yield* catalog.provider.update(providerID, (provider) => {
  140. provider.enabled = { via: "custom", data: {} }
  141. })
  142. yield* catalog.model.update(providerID, ModelV2.ID.make("old"), (model) => {
  143. model.time.released = DateTime.makeUnsafe(1000)
  144. })
  145. yield* catalog.model.update(providerID, ModelV2.ID.make("new"), (model) => {
  146. model.time.released = DateTime.makeUnsafe(2000)
  147. })
  148. const model = yield* catalog.model.default()
  149. expect(Option.getOrUndefined(model)?.id).toMatch("new")
  150. }),
  151. )
  152. it.effect("small model prefers small keyword candidates before cost scoring", () =>
  153. Effect.gen(function* () {
  154. const catalog = yield* Catalog.Service
  155. const providerID = ProviderV2.ID.make("test")
  156. yield* catalog.provider.update(providerID, () => {})
  157. yield* catalog.model.update(providerID, ModelV2.ID.make("cheap-large"), (model) => {
  158. model.capabilities.input = ["text"]
  159. model.capabilities.output = ["text"]
  160. model.cost = [{ input: 1, output: 1, cache: { read: 0, write: 0 } }]
  161. model.time.released = DateTime.makeUnsafe(Date.now())
  162. })
  163. yield* catalog.model.update(providerID, ModelV2.ID.make("expensive-mini"), (model) => {
  164. model.capabilities.input = ["text"]
  165. model.capabilities.output = ["text"]
  166. model.cost = [{ input: 10, output: 10, cache: { read: 0, write: 0 } }]
  167. model.time.released = DateTime.makeUnsafe(Date.now())
  168. })
  169. const model = yield* catalog.model.small(providerID)
  170. expect(Option.getOrUndefined(model)?.id).toMatch("expensive-mini")
  171. }),
  172. )
  173. })