provider.test.ts 12 KB

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