models-dev.test.ts 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275
  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 models.dev modes as separate models instead of variants", () =>
  30. Effect.gen(function* () {
  31. const integrations = yield* Integration.Service
  32. const catalog = yield* Catalog.Service
  33. const models = ModelsDev.Service.of({
  34. get: () =>
  35. Effect.succeed({
  36. acme: {
  37. id: "acme",
  38. name: "Acme",
  39. env: [],
  40. npm: "@ai-sdk/openai-compatible",
  41. api: "https://api.acme.test/v1",
  42. models: {
  43. "gpt-5.4": {
  44. id: "gpt-5.4",
  45. name: "GPT-5.4",
  46. family: "gpt",
  47. release_date: "2026-01-01",
  48. attachment: false,
  49. reasoning: true,
  50. temperature: true,
  51. tool_call: true,
  52. cost: {
  53. input: Money.USDPerMillionTokens.make(2.5),
  54. output: Money.USDPerMillionTokens.make(15),
  55. tiers: [
  56. {
  57. tier: { type: "context", size: 272_000 },
  58. input: Money.USDPerMillionTokens.make(3),
  59. output: Money.USDPerMillionTokens.make(18),
  60. cache_read: Money.USDPerMillionTokens.make(0.25),
  61. },
  62. ],
  63. context_over_200k: {
  64. input: Money.USDPerMillionTokens.make(5),
  65. output: Money.USDPerMillionTokens.make(22.5),
  66. cache_read: Money.USDPerMillionTokens.make(0.5),
  67. },
  68. },
  69. limit: { context: 1_050_000, input: 922_000, output: 128_000 },
  70. experimental: {
  71. modes: {
  72. fast: {
  73. cost: {
  74. input: Money.USDPerMillionTokens.make(5),
  75. output: Money.USDPerMillionTokens.make(30),
  76. cache_read: Money.USDPerMillionTokens.make(0.5),
  77. },
  78. provider: {
  79. headers: { "x-mode": "fast" },
  80. body: { service_tier: "priority" },
  81. },
  82. },
  83. },
  84. },
  85. },
  86. },
  87. },
  88. } satisfies Record<string, ModelsDev.Provider>),
  89. refresh: () => Effect.void,
  90. })
  91. yield* ModelsDevPlugin.effect(
  92. host({
  93. catalog: catalogHost(catalog),
  94. integration: integrationHost(integrations),
  95. }),
  96. ).pipe(Effect.provideService(ModelsDev.Service, models))
  97. const providerID = ProviderV2.ID.make("acme")
  98. const base = yield* catalog.model.get(providerID, ModelV2.ID.make("gpt-5.4"))
  99. const fast = yield* catalog.model.get(providerID, ModelV2.ID.make("gpt-5.4-fast"))
  100. expect(base?.variants).toEqual([])
  101. expect(base?.body).toEqual({})
  102. expect(fast).toMatchObject({
  103. id: "gpt-5.4-fast",
  104. modelID: "gpt-5.4",
  105. providerID: "acme",
  106. name: "GPT-5.4 Fast",
  107. package: ProviderV2.aisdk("@ai-sdk/openai-compatible"),
  108. settings: { baseURL: "https://api.acme.test/v1" },
  109. headers: { "x-mode": "fast" },
  110. body: { service_tier: "priority" },
  111. variants: [],
  112. })
  113. expect(fast?.cost).toEqual([
  114. {
  115. input: Money.USDPerMillionTokens.make(5),
  116. output: Money.USDPerMillionTokens.make(30),
  117. cache: {
  118. read: Money.USDPerMillionTokens.make(0.5),
  119. write: Money.USDPerMillionTokens.zero,
  120. },
  121. },
  122. {
  123. tier: { type: "context", size: 272_000 },
  124. input: Money.USDPerMillionTokens.make(3),
  125. output: Money.USDPerMillionTokens.make(18),
  126. cache: {
  127. read: Money.USDPerMillionTokens.make(0.25),
  128. write: Money.USDPerMillionTokens.zero,
  129. },
  130. },
  131. {
  132. tier: { type: "context", size: 200_000 },
  133. input: Money.USDPerMillionTokens.make(5),
  134. output: Money.USDPerMillionTokens.make(22.5),
  135. cache: {
  136. read: Money.USDPerMillionTokens.make(0.5),
  137. write: Money.USDPerMillionTokens.zero,
  138. },
  139. },
  140. ])
  141. }),
  142. )
  143. it.effect("registers key methods for providers with environment variables", () =>
  144. Effect.acquireUseRelease(
  145. Effect.sync(() => {
  146. const previous = {
  147. path: Flag.OPENCODE_MODELS_PATH,
  148. disabled: Flag.OPENCODE_DISABLE_MODELS_FETCH,
  149. }
  150. Flag.OPENCODE_MODELS_PATH = path.join(import.meta.dir, "fixtures", "models-dev.json")
  151. Flag.OPENCODE_DISABLE_MODELS_FETCH = true
  152. return previous
  153. }),
  154. () =>
  155. Effect.gen(function* () {
  156. const integrations = yield* Integration.Service
  157. const catalog = yield* Catalog.Service
  158. yield* ModelsDevPlugin.effect(
  159. host({
  160. catalog: catalogHost(catalog),
  161. integration: integrationHost(integrations),
  162. }),
  163. )
  164. expect(yield* integrations.list()).toEqual([
  165. new Integration.Info({
  166. id: Integration.ID.make("acme"),
  167. name: "Acme",
  168. methods: [
  169. { type: "key" },
  170. {
  171. type: "env",
  172. names: ["ACME_API_KEY"],
  173. },
  174. ],
  175. connections: [],
  176. }),
  177. ])
  178. }).pipe(Effect.provide(AppNodeBuilder.build(ModelsDev.node))),
  179. (previous) =>
  180. Effect.sync(() => {
  181. Flag.OPENCODE_MODELS_PATH = previous.path
  182. Flag.OPENCODE_DISABLE_MODELS_FETCH = previous.disabled
  183. }),
  184. ),
  185. )
  186. it.effect("converts reasoning options into settings variants", () =>
  187. Effect.acquireUseRelease(
  188. Effect.sync(() => {
  189. const previous = {
  190. path: Flag.OPENCODE_MODELS_PATH,
  191. disabled: Flag.OPENCODE_DISABLE_MODELS_FETCH,
  192. }
  193. Flag.OPENCODE_MODELS_PATH = path.join(import.meta.dir, "fixtures", "models-dev-reasoning.json")
  194. Flag.OPENCODE_DISABLE_MODELS_FETCH = true
  195. return previous
  196. }),
  197. () =>
  198. Effect.gen(function* () {
  199. const catalog = yield* Catalog.Service
  200. const integrations = yield* Integration.Service
  201. yield* ModelsDevPlugin.effect(
  202. host({
  203. catalog: catalogHost(catalog),
  204. integration: integrationHost(integrations),
  205. }),
  206. )
  207. const model = yield* catalog.model.get(ProviderV2.ID.openai, ModelV2.ID.make("gpt-reasoning"))
  208. expect(model?.variants?.map((variant) => variant.id)).toEqual([
  209. ModelV2.VariantID.make("low"),
  210. ModelV2.VariantID.make("high"),
  211. ])
  212. expect(model?.variants).toContainEqual({
  213. id: ModelV2.VariantID.make("low"),
  214. settings: {
  215. reasoningEffort: "low",
  216. reasoningSummary: "auto",
  217. include: ["reasoning.encrypted_content"],
  218. },
  219. })
  220. expect(model?.variants).toContainEqual({
  221. id: ModelV2.VariantID.make("high"),
  222. settings: {
  223. reasoningEffort: "high",
  224. reasoningSummary: "auto",
  225. include: ["reasoning.encrypted_content"],
  226. },
  227. })
  228. const mode = yield* catalog.model.get(ProviderV2.ID.openai, ModelV2.ID.make("gpt-reasoning-high"))
  229. expect(mode).toMatchObject({
  230. id: "gpt-reasoning-high",
  231. name: "GPT Reasoning High",
  232. headers: { "x-mode": "high" },
  233. body: { service_tier: "priority" },
  234. })
  235. expect(mode?.variants?.map((variant) => variant.id)).toEqual([
  236. ModelV2.VariantID.make("low"),
  237. ModelV2.VariantID.make("high"),
  238. ])
  239. const budgetModel = yield* catalog.model.get(ProviderV2.ID.anthropic, ModelV2.ID.make("claude-budget"))
  240. expect(budgetModel?.variants).toContainEqual({
  241. id: ModelV2.VariantID.make("high"),
  242. settings: { thinking: { type: "enabled", budgetTokens: 16000 } },
  243. })
  244. expect(budgetModel?.variants).toContainEqual({
  245. id: ModelV2.VariantID.make("max"),
  246. settings: { thinking: { type: "enabled", budgetTokens: 64000 } },
  247. })
  248. const anthropicEffortModel = yield* catalog.model.get(
  249. ProviderV2.ID.anthropic,
  250. ModelV2.ID.make("claude-effort"),
  251. )
  252. expect(anthropicEffortModel?.variants).toContainEqual({
  253. id: ModelV2.VariantID.make("low"),
  254. settings: { thinking: { type: "adaptive", display: "summarized" }, effort: "low" },
  255. })
  256. }).pipe(Effect.provide(AppNodeBuilder.build(ModelsDev.node))),
  257. (previous) =>
  258. Effect.sync(() => {
  259. Flag.OPENCODE_MODELS_PATH = previous.path
  260. Flag.OPENCODE_DISABLE_MODELS_FETCH = previous.disabled
  261. }),
  262. ),
  263. )
  264. })