models-dev.test.ts 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321
  1. import path from "path"
  2. import { describe, expect } from "bun:test"
  3. import { Money } from "@opencode-ai/schema/money"
  4. import { Effect, Layer } from "effect"
  5. import { Catalog } from "@opencode-ai/core/catalog"
  6. import { Integration } from "@opencode-ai/core/integration"
  7. import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
  8. import { LayerNode } from "@opencode-ai/core/effect/layer-node"
  9. import { EventV2 } from "@opencode-ai/core/event"
  10. import { Flag } from "@opencode-ai/core/flag/flag"
  11. import { Location } from "@opencode-ai/core/location"
  12. import { ModelV2 } from "@opencode-ai/core/model"
  13. import { ModelsDev } from "@opencode-ai/core/models-dev"
  14. import { ModelsDevPlugin } from "@opencode-ai/core/plugin/models-dev"
  15. import { ProviderV2 } from "@opencode-ai/core/provider"
  16. import { AbsolutePath } from "@opencode-ai/core/schema"
  17. import { location } from "../fixture/location"
  18. import { testEffect } from "../lib/effect"
  19. import { catalogHost, host, integrationHost } from "./host"
  20. const locationLayer = Layer.succeed(
  21. Location.Service,
  22. Location.Service.of(location({ directory: AbsolutePath.make(import.meta.dir) })),
  23. )
  24. const layer = AppNodeBuilder.build(LayerNode.group([Catalog.node, Integration.node, EventV2.node]), [
  25. [Location.node, locationLayer],
  26. ])
  27. const it = testEffect(layer)
  28. describe("ModelsDevPlugin", () => {
  29. it.effect("projects normalized models.dev snapshots into the catalog", () =>
  30. Effect.gen(function* () {
  31. const integrations = yield* Integration.Service
  32. const catalog = yield* Catalog.Service
  33. const providerID = ProviderV2.ID.make("acme")
  34. const modelID = ModelV2.ID.make("gpt-5.4")
  35. const models = ModelsDev.Service.of({
  36. get: () =>
  37. Effect.succeed([
  38. {
  39. info: {
  40. id: providerID,
  41. name: "Acme",
  42. package: ProviderV2.aisdk("@ai-sdk/openai-compatible"),
  43. settings: { baseURL: "https://api.acme.test/v1" },
  44. },
  45. environment: [],
  46. models: [
  47. {
  48. id: modelID,
  49. modelID,
  50. providerID,
  51. name: "GPT-5.4",
  52. family: ModelV2.Family.make("gpt"),
  53. capabilities: { tools: true, input: [], output: [] },
  54. variants: [],
  55. time: { released: Date.parse("2026-01-01") },
  56. cost: [
  57. {
  58. input: Money.USDPerMillionTokens.make(2.5),
  59. output: Money.USDPerMillionTokens.make(15),
  60. cache: {
  61. read: Money.USDPerMillionTokens.zero,
  62. write: Money.USDPerMillionTokens.zero,
  63. },
  64. },
  65. {
  66. tier: { type: "context", size: 272_000 },
  67. input: Money.USDPerMillionTokens.make(3),
  68. output: Money.USDPerMillionTokens.make(18),
  69. cache: {
  70. read: Money.USDPerMillionTokens.make(0.25),
  71. write: Money.USDPerMillionTokens.zero,
  72. },
  73. },
  74. {
  75. tier: { type: "context", size: 200_000 },
  76. input: Money.USDPerMillionTokens.make(5),
  77. output: Money.USDPerMillionTokens.make(22.5),
  78. cache: {
  79. read: Money.USDPerMillionTokens.make(0.5),
  80. write: Money.USDPerMillionTokens.zero,
  81. },
  82. },
  83. ],
  84. status: "active",
  85. enabled: true,
  86. limit: { context: 1_050_000, input: 922_000, output: 128_000 },
  87. },
  88. {
  89. id: ModelV2.ID.make("gpt-5.4-fast"),
  90. modelID,
  91. providerID,
  92. name: "GPT-5.4 Fast",
  93. family: ModelV2.Family.make("gpt"),
  94. package: ProviderV2.aisdk("@ai-sdk/openai-compatible"),
  95. settings: { baseURL: "https://api.acme.test/v1" },
  96. headers: { "x-mode": "fast" },
  97. body: { service_tier: "priority" },
  98. capabilities: { tools: true, input: [], output: [] },
  99. variants: [],
  100. time: { released: Date.parse("2026-01-01") },
  101. cost: [
  102. {
  103. input: Money.USDPerMillionTokens.make(5),
  104. output: Money.USDPerMillionTokens.make(30),
  105. cache: {
  106. read: Money.USDPerMillionTokens.make(0.5),
  107. write: Money.USDPerMillionTokens.zero,
  108. },
  109. },
  110. {
  111. tier: { type: "context", size: 272_000 },
  112. input: Money.USDPerMillionTokens.make(3),
  113. output: Money.USDPerMillionTokens.make(18),
  114. cache: {
  115. read: Money.USDPerMillionTokens.make(0.25),
  116. write: Money.USDPerMillionTokens.zero,
  117. },
  118. },
  119. {
  120. tier: { type: "context", size: 200_000 },
  121. input: Money.USDPerMillionTokens.make(5),
  122. output: Money.USDPerMillionTokens.make(22.5),
  123. cache: {
  124. read: Money.USDPerMillionTokens.make(0.5),
  125. write: Money.USDPerMillionTokens.zero,
  126. },
  127. },
  128. ],
  129. status: "active",
  130. enabled: true,
  131. limit: { context: 1_050_000, input: 922_000, output: 128_000 },
  132. },
  133. ],
  134. },
  135. ] satisfies readonly ModelsDev.Snapshot[]),
  136. refresh: () => Effect.void,
  137. })
  138. yield* ModelsDevPlugin.effect(
  139. host({
  140. catalog: catalogHost(catalog),
  141. integration: integrationHost(integrations),
  142. }),
  143. ).pipe(Effect.provideService(ModelsDev.Service, models))
  144. const base = yield* catalog.model.get(providerID, ModelV2.ID.make("gpt-5.4"))
  145. const fast = yield* catalog.model.get(providerID, ModelV2.ID.make("gpt-5.4-fast"))
  146. expect(base?.variants).toEqual([])
  147. expect(base?.body).toBeUndefined()
  148. expect(fast).toMatchObject({
  149. id: "gpt-5.4-fast",
  150. modelID: "gpt-5.4",
  151. providerID: "acme",
  152. name: "GPT-5.4 Fast",
  153. package: ProviderV2.aisdk("@ai-sdk/openai-compatible"),
  154. settings: { baseURL: "https://api.acme.test/v1" },
  155. headers: { "x-mode": "fast" },
  156. body: { service_tier: "priority" },
  157. variants: [],
  158. })
  159. expect(fast?.cost).toEqual([
  160. {
  161. input: Money.USDPerMillionTokens.make(5),
  162. output: Money.USDPerMillionTokens.make(30),
  163. cache: {
  164. read: Money.USDPerMillionTokens.make(0.5),
  165. write: Money.USDPerMillionTokens.zero,
  166. },
  167. },
  168. {
  169. tier: { type: "context", size: 272_000 },
  170. input: Money.USDPerMillionTokens.make(3),
  171. output: Money.USDPerMillionTokens.make(18),
  172. cache: {
  173. read: Money.USDPerMillionTokens.make(0.25),
  174. write: Money.USDPerMillionTokens.zero,
  175. },
  176. },
  177. {
  178. tier: { type: "context", size: 200_000 },
  179. input: Money.USDPerMillionTokens.make(5),
  180. output: Money.USDPerMillionTokens.make(22.5),
  181. cache: {
  182. read: Money.USDPerMillionTokens.make(0.5),
  183. write: Money.USDPerMillionTokens.zero,
  184. },
  185. },
  186. ])
  187. }),
  188. )
  189. it.effect("registers key methods for providers with environment variables", () =>
  190. Effect.acquireUseRelease(
  191. Effect.sync(() => {
  192. const previous = {
  193. path: Flag.OPENCODE_MODELS_PATH,
  194. disabled: Flag.OPENCODE_DISABLE_MODELS_FETCH,
  195. }
  196. Flag.OPENCODE_MODELS_PATH = path.join(import.meta.dir, "fixtures", "models-dev.json")
  197. Flag.OPENCODE_DISABLE_MODELS_FETCH = true
  198. return previous
  199. }),
  200. () =>
  201. Effect.gen(function* () {
  202. const integrations = yield* Integration.Service
  203. const catalog = yield* Catalog.Service
  204. yield* ModelsDevPlugin.effect(
  205. host({
  206. catalog: catalogHost(catalog),
  207. integration: integrationHost(integrations),
  208. }),
  209. )
  210. expect(yield* integrations.list()).toEqual([
  211. Integration.Info.make({
  212. id: Integration.ID.make("acme"),
  213. name: "Acme",
  214. methods: [
  215. { type: "key" },
  216. {
  217. type: "env",
  218. names: ["ACME_API_KEY"],
  219. },
  220. ],
  221. connections: [],
  222. }),
  223. ])
  224. }).pipe(Effect.provide(AppNodeBuilder.build(ModelsDev.node))),
  225. (previous) =>
  226. Effect.sync(() => {
  227. Flag.OPENCODE_MODELS_PATH = previous.path
  228. Flag.OPENCODE_DISABLE_MODELS_FETCH = previous.disabled
  229. }),
  230. ),
  231. )
  232. it.effect("converts reasoning options into settings variants", () =>
  233. Effect.acquireUseRelease(
  234. Effect.sync(() => {
  235. const previous = {
  236. path: Flag.OPENCODE_MODELS_PATH,
  237. disabled: Flag.OPENCODE_DISABLE_MODELS_FETCH,
  238. }
  239. Flag.OPENCODE_MODELS_PATH = path.join(import.meta.dir, "fixtures", "models-dev-reasoning.json")
  240. Flag.OPENCODE_DISABLE_MODELS_FETCH = true
  241. return previous
  242. }),
  243. () =>
  244. Effect.gen(function* () {
  245. const catalog = yield* Catalog.Service
  246. const integrations = yield* Integration.Service
  247. yield* ModelsDevPlugin.effect(
  248. host({
  249. catalog: catalogHost(catalog),
  250. integration: integrationHost(integrations),
  251. }),
  252. )
  253. const model = yield* catalog.model.get(ProviderV2.ID.openai, ModelV2.ID.make("gpt-reasoning"))
  254. expect(model?.variants?.map((variant) => variant.id)).toEqual([
  255. ModelV2.VariantID.make("low"),
  256. ModelV2.VariantID.make("high"),
  257. ])
  258. expect(model?.variants).toContainEqual({
  259. id: ModelV2.VariantID.make("low"),
  260. settings: {
  261. reasoningEffort: "low",
  262. reasoningSummary: "auto",
  263. include: ["reasoning.encrypted_content"],
  264. },
  265. })
  266. expect(model?.variants).toContainEqual({
  267. id: ModelV2.VariantID.make("high"),
  268. settings: {
  269. reasoningEffort: "high",
  270. reasoningSummary: "auto",
  271. include: ["reasoning.encrypted_content"],
  272. },
  273. })
  274. const mode = yield* catalog.model.get(ProviderV2.ID.openai, ModelV2.ID.make("gpt-reasoning-high"))
  275. expect(mode).toMatchObject({
  276. id: "gpt-reasoning-high",
  277. name: "GPT Reasoning High",
  278. headers: { "x-mode": "high" },
  279. body: { service_tier: "priority" },
  280. })
  281. expect(mode?.variants?.map((variant) => variant.id)).toEqual([
  282. ModelV2.VariantID.make("low"),
  283. ModelV2.VariantID.make("high"),
  284. ])
  285. const budgetModel = yield* catalog.model.get(ProviderV2.ID.anthropic, ModelV2.ID.make("claude-budget"))
  286. expect(budgetModel?.variants).toContainEqual({
  287. id: ModelV2.VariantID.make("high"),
  288. settings: { thinking: { type: "enabled", budgetTokens: 16000 } },
  289. })
  290. expect(budgetModel?.variants).toContainEqual({
  291. id: ModelV2.VariantID.make("max"),
  292. settings: { thinking: { type: "enabled", budgetTokens: 64000 } },
  293. })
  294. const anthropicEffortModel = yield* catalog.model.get(
  295. ProviderV2.ID.anthropic,
  296. ModelV2.ID.make("claude-effort"),
  297. )
  298. expect(anthropicEffortModel?.variants).toContainEqual({
  299. id: ModelV2.VariantID.make("low"),
  300. settings: { thinking: { type: "adaptive", display: "summarized" }, effort: "low" },
  301. })
  302. }).pipe(Effect.provide(AppNodeBuilder.build(ModelsDev.node))),
  303. (previous) =>
  304. Effect.sync(() => {
  305. Flag.OPENCODE_MODELS_PATH = previous.path
  306. Flag.OPENCODE_DISABLE_MODELS_FETCH = previous.disabled
  307. }),
  308. ),
  309. )
  310. })