provider.test.ts 9.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274
  1. import { describe, expect } from "bun:test"
  2. import { Effect, Schema } from "effect"
  3. import { Catalog } from "@opencode-ai/core/catalog"
  4. import { Config } from "@opencode-ai/core/config"
  5. import { ConfigProviderPlugin } from "@opencode-ai/core/config/plugin/provider"
  6. import { Integration } from "@opencode-ai/core/integration"
  7. import { ModelV2 } from "@opencode-ai/core/model"
  8. import { PluginV2 } from "@opencode-ai/core/plugin"
  9. import { PluginHost } from "@opencode-ai/core/plugin/host"
  10. import { ProviderV2 } from "@opencode-ai/core/provider"
  11. import { testEffect } from "../lib/effect"
  12. import { PluginTestLayer } from "../plugin/fixture"
  13. const it = testEffect(PluginTestLayer)
  14. const addPlugin = Effect.fn(function* (config: Config.Interface) {
  15. const plugin = yield* PluginV2.Service
  16. const host = yield* PluginHost.make()
  17. yield* plugin.add({
  18. ...ConfigProviderPlugin.Plugin,
  19. effect: ConfigProviderPlugin.Plugin.effect(host).pipe(Effect.provideService(Config.Service, config)),
  20. })
  21. })
  22. function required<T>(value: T | undefined): T {
  23. if (value === undefined) throw new Error("Expected value")
  24. return value
  25. }
  26. function withEnv<A, E, R>(vars: Record<string, string | undefined>, effect: () => Effect.Effect<A, E, R>) {
  27. return Effect.acquireUseRelease(
  28. Effect.sync(() => {
  29. const previous = Object.fromEntries(Object.keys(vars).map((key) => [key, process.env[key]]))
  30. Object.entries(vars).forEach(([key, value]) => {
  31. if (value === undefined) delete process.env[key]
  32. else process.env[key] = value
  33. })
  34. return previous
  35. }),
  36. effect,
  37. (previous) =>
  38. Effect.sync(() =>
  39. Object.entries(previous).forEach(([key, value]) => {
  40. if (value === undefined) delete process.env[key]
  41. else process.env[key] = value
  42. }),
  43. ),
  44. )
  45. }
  46. function request(headers: Record<string, string>, variant?: string) {
  47. return {
  48. headers,
  49. variant,
  50. }
  51. }
  52. const decode = Schema.decodeUnknownSync(Config.Info)
  53. describe("ConfigProviderPlugin.Plugin", () => {
  54. it.effect("partitions existing model variant bodies without changing config shape", () =>
  55. Effect.gen(function* () {
  56. const catalog = yield* Catalog.Service
  57. const providerID = ProviderV2.ID.opencode
  58. const modelID = ModelV2.ID.make("alpha-gpt-next")
  59. const config = Config.Service.of({
  60. entries: () =>
  61. Effect.succeed([
  62. new Config.Document({
  63. type: "document",
  64. info: decode({
  65. providers: {
  66. opencode: {
  67. api: { type: "aisdk", package: "@ai-sdk/openai", url: "https://opencode.test/v1" },
  68. models: {
  69. "alpha-gpt-next": {
  70. variants: [
  71. {
  72. id: "high",
  73. body: {
  74. reasoningEffort: "high",
  75. reasoningSummary: "auto",
  76. include: ["reasoning.encrypted_content"],
  77. },
  78. },
  79. ],
  80. },
  81. },
  82. },
  83. },
  84. }),
  85. }),
  86. ]),
  87. })
  88. yield* addPlugin(config)
  89. const model = required(yield* catalog.model.get(providerID, modelID))
  90. expect(model.variants).toMatchObject([
  91. {
  92. id: "high",
  93. body: {},
  94. options: {
  95. reasoningEffort: "high",
  96. reasoningSummary: "auto",
  97. include: ["reasoning.encrypted_content"],
  98. },
  99. },
  100. ])
  101. }),
  102. )
  103. it.effect("uses the effective provider package across layered config", () =>
  104. Effect.gen(function* () {
  105. const catalog = yield* Catalog.Service
  106. const providerID = ProviderV2.ID.opencode
  107. const modelID = ModelV2.ID.make("alpha-gpt-next")
  108. const config = Config.Service.of({
  109. entries: () =>
  110. Effect.succeed([
  111. new Config.Document({
  112. type: "document",
  113. info: decode({
  114. providers: {
  115. opencode: {
  116. api: { type: "aisdk", package: "@ai-sdk/openai", url: "https://opencode.test/v1" },
  117. },
  118. },
  119. }),
  120. }),
  121. new Config.Document({
  122. type: "document",
  123. info: decode({
  124. providers: {
  125. opencode: {
  126. models: {
  127. "alpha-gpt-next": {
  128. variants: [{ id: "high", body: { reasoningEffort: "high" } }],
  129. },
  130. },
  131. },
  132. },
  133. }),
  134. }),
  135. ]),
  136. })
  137. yield* addPlugin(config)
  138. const model = required(yield* catalog.model.get(providerID, modelID))
  139. expect(model.variants[0]).toMatchObject({
  140. id: "high",
  141. body: {},
  142. options: { reasoningEffort: "high" },
  143. })
  144. }),
  145. )
  146. it.effect("loads configured providers and applies later model overrides", () =>
  147. withEnv({ CUSTOM_API_KEY: "secret" }, () =>
  148. Effect.gen(function* () {
  149. const catalog = yield* Catalog.Service
  150. const integrations = yield* Integration.Service
  151. const providerID = ProviderV2.ID.make("custom")
  152. const modelID = ModelV2.ID.make("chat")
  153. const config = Config.Service.of({
  154. entries: () =>
  155. Effect.succeed([
  156. new Config.Document({
  157. type: "document",
  158. info: decode({
  159. model: "custom/first",
  160. providers: {
  161. custom: {
  162. name: "Configured",
  163. env: ["CUSTOM_API_KEY"],
  164. api: { type: "native", settings: {} },
  165. request: request({ first: "first", shared: "first" }),
  166. models: {
  167. chat: {
  168. name: "First",
  169. capabilities: { tools: true, input: ["text"], output: ["text"] },
  170. disabled: true,
  171. limit: { context: 100, output: 50 },
  172. cost: { input: 1, output: 2 },
  173. request: request({ first: "first", shared: "first" }, "retained"),
  174. variants: [
  175. {
  176. id: "fast",
  177. headers: { first: "first", shared: "first" },
  178. },
  179. ],
  180. },
  181. },
  182. },
  183. },
  184. }),
  185. }),
  186. new Config.Document({
  187. type: "document",
  188. info: decode({
  189. model: "custom/default",
  190. providers: {
  191. custom: {
  192. api: { type: "aisdk", package: "custom-sdk", url: "https://example.test" },
  193. request: request({ last: "last", shared: "last" }),
  194. models: {
  195. default: {
  196. name: "Default",
  197. },
  198. chat: {
  199. api: { id: "api-chat" },
  200. name: "Last",
  201. limit: { output: 75 },
  202. request: request({ last: "last", shared: "last" }),
  203. variants: [
  204. {
  205. id: "fast",
  206. headers: { last: "last", shared: "last" },
  207. },
  208. {
  209. id: "slow",
  210. headers: { slow: "slow" },
  211. },
  212. ],
  213. },
  214. },
  215. },
  216. },
  217. }),
  218. }),
  219. new Config.Document({
  220. type: "document",
  221. info: decode({
  222. providers: {
  223. custom: { name: "Renamed" },
  224. },
  225. }),
  226. }),
  227. ]),
  228. })
  229. yield* addPlugin(config)
  230. const provider = required(yield* catalog.provider.get(providerID))
  231. const model = required(yield* catalog.model.get(providerID, modelID))
  232. expect((yield* catalog.model.default())?.id).toBe(ModelV2.ID.make("default"))
  233. expect(provider.name).toBe("Renamed")
  234. expect((yield* integrations.get(Integration.ID.make("custom")))?.methods).toContainEqual({
  235. type: "env",
  236. names: ["CUSTOM_API_KEY"],
  237. })
  238. expect((yield* integrations.get(Integration.ID.make("custom")))?.name).toBe("Renamed")
  239. expect(provider.disabled).toBeUndefined()
  240. expect(provider.api).toEqual({ type: "aisdk", package: "custom-sdk", url: "https://example.test" })
  241. expect(provider.request.headers).toEqual({ first: "first", shared: "last", last: "last" })
  242. expect(model.api.id).toBe(ModelV2.ID.make("api-chat"))
  243. expect(model.name).toBe("Last")
  244. expect(model.capabilities).toEqual({ tools: true, input: ["text"], output: ["text"] })
  245. expect(model.enabled).toBe(false)
  246. expect(model.limit).toEqual({ context: 100, output: 75 })
  247. expect(model.cost).toEqual([{ input: 1, output: 2, cache: { read: 0, write: 0 }, tier: undefined }])
  248. expect(model.request.headers).toEqual({ first: "first", shared: "last", last: "last" })
  249. expect(model.request.variant).toBe("retained")
  250. expect(model.variants.map((variant) => variant.id)).toEqual([
  251. ModelV2.VariantID.make("fast"),
  252. ModelV2.VariantID.make("slow"),
  253. ])
  254. expect(model.variants[0]?.headers).toEqual({ first: "first", shared: "last", last: "last" })
  255. expect(model.variants[1]?.headers).toEqual({ slow: "slow" })
  256. }),
  257. ),
  258. )
  259. })