provider.test.ts 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346
  1. import { describe, expect } from "bun:test"
  2. import { Money } from "@opencode-ai/schema/money"
  3. import { Effect, Schema, Stream } from "effect"
  4. import { Catalog } from "@opencode-ai/core/catalog"
  5. import { Config } from "@opencode-ai/core/config"
  6. import { ConfigProviderPlugin } from "@opencode-ai/core/config/plugin/provider"
  7. import { Integration } from "@opencode-ai/core/integration"
  8. import { Model } from "@opencode-ai/core/model"
  9. import { Plugin } from "@opencode-ai/core/plugin"
  10. import { PluginHost } from "@opencode-ai/core/plugin/host"
  11. import { Provider } from "@opencode-ai/core/provider"
  12. import { testEffect } from "../lib/effect"
  13. import { PluginTestLayer } from "../plugin/fixture"
  14. const it = testEffect(PluginTestLayer)
  15. const addPlugin = Effect.fn(function* (entries: Config.Entry[]) {
  16. const plugin = yield* Plugin.Service
  17. const host = yield* PluginHost.make(plugin)
  18. yield* ConfigProviderPlugin.Plugin.effect(host).pipe(Effect.provide(Config.testLayer(entries)))
  19. })
  20. function required<T>(value: T | undefined): T {
  21. if (value === undefined) throw new Error("Expected value")
  22. return value
  23. }
  24. function withEnv<A, E, R>(vars: Record<string, string | undefined>, effect: () => Effect.Effect<A, E, R>) {
  25. return Effect.acquireUseRelease(
  26. Effect.sync(() => {
  27. const previous = Object.fromEntries(Object.keys(vars).map((key) => [key, process.env[key]]))
  28. Object.entries(vars).forEach(([key, value]) => {
  29. if (value === undefined) delete process.env[key]
  30. else process.env[key] = value
  31. })
  32. return previous
  33. }),
  34. effect,
  35. (previous) =>
  36. Effect.sync(() =>
  37. Object.entries(previous).forEach(([key, value]) => {
  38. if (value === undefined) delete process.env[key]
  39. else process.env[key] = value
  40. }),
  41. ),
  42. )
  43. }
  44. const decode = Schema.decodeUnknownSync(Config.Info)
  45. describe("ConfigProviderPlugin.Plugin", () => {
  46. it.effect("defaults custom models to agent capabilities", () =>
  47. Effect.gen(function* () {
  48. const catalog = yield* Catalog.Service
  49. const providerID = Provider.ID.make("custom")
  50. const modelID = Model.ID.make("chat")
  51. const entries = [
  52. new Config.Document({
  53. type: "document",
  54. info: decode({
  55. providers: {
  56. custom: {
  57. package: "aisdk:@ai-sdk/openai-compatible",
  58. models: { chat: {} },
  59. },
  60. },
  61. }),
  62. }),
  63. ]
  64. yield* addPlugin(entries)
  65. const model = required(yield* catalog.model.get(providerID, modelID))
  66. expect(model.capabilities).toEqual({ tools: true, input: ["text", "image"], output: ["text"] })
  67. }),
  68. )
  69. it.effect("preserves catalog capabilities unless config overrides them", () =>
  70. Effect.gen(function* () {
  71. const catalog = yield* Catalog.Service
  72. const providerID = Provider.ID.make("custom")
  73. const inheritedID = Model.ID.make("inherited")
  74. const overriddenID = Model.ID.make("overridden")
  75. yield* catalog.transform((draft) => {
  76. draft.model.update(providerID, inheritedID, (model) => {
  77. model.capabilities = { tools: false, input: ["text"], output: ["text"] }
  78. })
  79. draft.model.update(providerID, overriddenID, (model) => {
  80. model.capabilities = { tools: false, input: ["text"], output: ["text"] }
  81. })
  82. })
  83. const entries = [
  84. new Config.Document({
  85. type: "document",
  86. info: decode({
  87. providers: {
  88. custom: {
  89. package: "aisdk:@ai-sdk/openai-compatible",
  90. models: {
  91. inherited: { name: "Inherited" },
  92. overridden: {
  93. capabilities: { tools: true, input: ["text", "image"], output: ["text"] },
  94. },
  95. },
  96. },
  97. },
  98. }),
  99. }),
  100. ]
  101. yield* addPlugin(entries)
  102. expect((yield* catalog.model.get(providerID, inheritedID))?.capabilities).toEqual({
  103. tools: false,
  104. input: ["text"],
  105. output: ["text"],
  106. })
  107. expect((yield* catalog.model.get(providerID, overriddenID))?.capabilities).toEqual({
  108. tools: true,
  109. input: ["text", "image"],
  110. output: ["text"],
  111. })
  112. }),
  113. )
  114. it.effect("keeps configured model variant bodies unchanged", () =>
  115. Effect.gen(function* () {
  116. const catalog = yield* Catalog.Service
  117. const providerID = Provider.ID.opencode
  118. const modelID = Model.ID.make("alpha-gpt-next")
  119. const entries = [
  120. new Config.Document({
  121. type: "document",
  122. info: decode({
  123. providers: {
  124. opencode: {
  125. package: "aisdk:@ai-sdk/openai",
  126. settings: { baseURL: "https://opencode.test/v1" },
  127. models: {
  128. "alpha-gpt-next": {
  129. variants: [
  130. {
  131. id: "high",
  132. body: {
  133. reasoningEffort: "high",
  134. reasoningSummary: "auto",
  135. include: ["reasoning.encrypted_content"],
  136. },
  137. },
  138. ],
  139. },
  140. },
  141. },
  142. },
  143. }),
  144. }),
  145. ]
  146. yield* addPlugin(entries)
  147. const model = required(yield* catalog.model.get(providerID, modelID))
  148. expect(model.variants).toMatchObject([
  149. {
  150. id: "high",
  151. body: {
  152. reasoningEffort: "high",
  153. reasoningSummary: "auto",
  154. include: ["reasoning.encrypted_content"],
  155. },
  156. },
  157. ])
  158. }),
  159. )
  160. it.effect("keeps layered model variant bodies unchanged", () =>
  161. Effect.gen(function* () {
  162. const catalog = yield* Catalog.Service
  163. const providerID = Provider.ID.opencode
  164. const modelID = Model.ID.make("alpha-gpt-next")
  165. const entries = [
  166. new Config.Document({
  167. type: "document",
  168. info: decode({
  169. providers: {
  170. opencode: {
  171. package: "aisdk:@ai-sdk/openai",
  172. settings: { baseURL: "https://opencode.test/v1" },
  173. },
  174. },
  175. }),
  176. }),
  177. new Config.Document({
  178. type: "document",
  179. info: decode({
  180. providers: {
  181. opencode: {
  182. models: {
  183. "alpha-gpt-next": {
  184. variants: [{ id: "high", body: { reasoningEffort: "high" } }],
  185. },
  186. },
  187. },
  188. },
  189. }),
  190. }),
  191. ]
  192. yield* addPlugin(entries)
  193. const model = required(yield* catalog.model.get(providerID, modelID))
  194. expect(model.variants?.[0]).toMatchObject({
  195. id: "high",
  196. body: { reasoningEffort: "high" },
  197. })
  198. }),
  199. )
  200. it.effect("loads configured providers and applies later model overrides", () =>
  201. withEnv({ CUSTOM_API_KEY: "secret" }, () =>
  202. Effect.gen(function* () {
  203. const catalog = yield* Catalog.Service
  204. const integrations = yield* Integration.Service
  205. const providerID = Provider.ID.make("custom")
  206. const modelID = Model.ID.make("chat")
  207. const entries = [
  208. new Config.Document({
  209. type: "document",
  210. info: decode({
  211. model: "custom/first",
  212. providers: {
  213. custom: {
  214. name: "Configured",
  215. env: ["CUSTOM_API_KEY"],
  216. package: "native",
  217. headers: { first: "first", shared: "first" },
  218. models: {
  219. chat: {
  220. name: "First",
  221. compatibility: { reasoningField: "vendor_reasoning" },
  222. capabilities: { tools: true, input: ["text"], output: ["text"] },
  223. disabled: true,
  224. limit: { context: 100, output: 50 },
  225. cost: { input: 1, output: 2 },
  226. settings: { retained: true },
  227. headers: { first: "first", shared: "first" },
  228. variants: [
  229. {
  230. id: "fast",
  231. headers: { first: "first", shared: "first" },
  232. },
  233. ],
  234. },
  235. },
  236. },
  237. },
  238. }),
  239. }),
  240. new Config.Document({
  241. type: "document",
  242. info: decode({
  243. model: "custom/default",
  244. providers: {
  245. custom: {
  246. package: "aisdk:custom-sdk",
  247. settings: { baseURL: "https://example.test" },
  248. headers: { last: "last", shared: "last" },
  249. models: {
  250. default: {
  251. name: "Default",
  252. },
  253. chat: {
  254. modelID: "api-chat",
  255. name: "Last",
  256. limit: { output: 75 },
  257. headers: { last: "last", shared: "last" },
  258. variants: [
  259. {
  260. id: "fast",
  261. headers: { last: "last", shared: "last" },
  262. },
  263. {
  264. id: "slow",
  265. headers: { slow: "slow" },
  266. },
  267. ],
  268. },
  269. },
  270. },
  271. },
  272. }),
  273. }),
  274. new Config.Document({
  275. type: "document",
  276. info: decode({
  277. providers: {
  278. custom: { name: "Renamed" },
  279. },
  280. }),
  281. }),
  282. ]
  283. yield* addPlugin(entries)
  284. const provider = required(yield* catalog.provider.get(providerID))
  285. const model = required(yield* catalog.model.get(providerID, modelID))
  286. expect((yield* catalog.model.default())?.id).toBe(Model.ID.make("default"))
  287. expect(provider.name).toBe("Renamed")
  288. expect((yield* integrations.get(Integration.ID.make("custom")))?.methods).toContainEqual({
  289. type: "env",
  290. names: ["CUSTOM_API_KEY"],
  291. })
  292. expect((yield* integrations.get(Integration.ID.make("custom")))?.name).toBe("Renamed")
  293. expect(provider.disabled).toBeUndefined()
  294. expect(provider.package).toBe("aisdk:custom-sdk")
  295. expect(provider.settings).toEqual({ baseURL: "https://example.test" })
  296. expect(provider.headers).toEqual({ first: "first", shared: "last", last: "last" })
  297. expect(model.id).toBe(modelID)
  298. expect(model.modelID).toBe(Model.ID.make("api-chat"))
  299. expect(model.name).toBe("Last")
  300. expect(model.compatibility).toEqual({ reasoningField: "vendor_reasoning" })
  301. expect(model.capabilities).toEqual({ tools: true, input: ["text"], output: ["text"] })
  302. expect(model.enabled).toBe(false)
  303. expect(model.limit).toEqual({ context: 100, output: 75 })
  304. expect(model.cost).toEqual([
  305. {
  306. input: Money.USDPerMillionTokens.make(1),
  307. output: Money.USDPerMillionTokens.make(2),
  308. cache: {
  309. read: Money.USDPerMillionTokens.zero,
  310. write: Money.USDPerMillionTokens.zero,
  311. },
  312. tier: undefined,
  313. },
  314. ])
  315. expect(model.settings).toEqual({ baseURL: "https://example.test", retained: true })
  316. expect(model.headers).toEqual({ first: "first", shared: "last", last: "last" })
  317. expect(model.variants?.map((variant) => variant.id)).toEqual([
  318. Model.VariantID.make("fast"),
  319. Model.VariantID.make("slow"),
  320. ])
  321. expect(model.variants?.[0]?.headers).toEqual({ first: "first", shared: "last", last: "last" })
  322. expect(model.variants?.[1]?.headers).toEqual({ slow: "slow" })
  323. }),
  324. ),
  325. )
  326. })