models-dev.test.ts 6.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188
  1. import path from "path"
  2. import { describe, expect } from "bun:test"
  3. import { Effect, Layer } from "effect"
  4. import { Catalog } from "@opencode-ai/core/catalog"
  5. import { Integration } from "@opencode-ai/core/integration"
  6. import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
  7. import { LayerNode } from "@opencode-ai/core/effect/layer-node"
  8. import { EventV2 } from "@opencode-ai/core/event"
  9. import { Flag } from "@opencode-ai/core/flag/flag"
  10. import { Location } from "@opencode-ai/core/location"
  11. import { ModelV2 } from "@opencode-ai/core/model"
  12. import { ModelsDev } from "@opencode-ai/core/models-dev"
  13. import { ModelsDevPlugin } from "@opencode-ai/core/plugin/models-dev"
  14. import { ProviderV2 } from "@opencode-ai/core/provider"
  15. import { AbsolutePath } from "@opencode-ai/core/schema"
  16. import { location } from "../fixture/location"
  17. import { testEffect } from "../lib/effect"
  18. import { catalogHost, host, integrationHost } from "./host"
  19. const locationLayer = Layer.succeed(
  20. Location.Service,
  21. Location.Service.of(location({ directory: AbsolutePath.make(import.meta.dir) })),
  22. )
  23. const layer = AppNodeBuilder.build(LayerNode.group([Catalog.node, Integration.node, EventV2.node]), [
  24. [Location.node, locationLayer],
  25. ])
  26. const it = testEffect(layer)
  27. describe("ModelsDevPlugin", () => {
  28. it.effect("projects models.dev modes as separate models instead of variants", () =>
  29. Effect.gen(function* () {
  30. const integrations = yield* Integration.Service
  31. const catalog = yield* Catalog.Service
  32. const models = ModelsDev.Service.of({
  33. get: () =>
  34. Effect.succeed({
  35. acme: {
  36. id: "acme",
  37. name: "Acme",
  38. env: [],
  39. npm: "@ai-sdk/openai-compatible",
  40. api: "https://api.acme.test/v1",
  41. models: {
  42. "gpt-5.4": {
  43. id: "gpt-5.4",
  44. name: "GPT-5.4",
  45. family: "gpt",
  46. release_date: "2026-01-01",
  47. attachment: false,
  48. reasoning: true,
  49. temperature: true,
  50. tool_call: true,
  51. cost: {
  52. input: 2.5,
  53. output: 15,
  54. tiers: [
  55. {
  56. tier: { type: "context", size: 272_000 },
  57. input: 3,
  58. output: 18,
  59. cache_read: 0.25,
  60. },
  61. ],
  62. context_over_200k: { input: 5, output: 22.5, cache_read: 0.5 },
  63. },
  64. limit: { context: 1_050_000, input: 922_000, output: 128_000 },
  65. experimental: {
  66. modes: {
  67. fast: {
  68. cost: { input: 5, output: 30, cache_read: 0.5 },
  69. provider: {
  70. headers: { "x-mode": "fast" },
  71. body: { service_tier: "priority" },
  72. },
  73. },
  74. },
  75. },
  76. },
  77. "deepseek-v4-flash": {
  78. id: "deepseek-v4-flash",
  79. name: "DeepSeek V4 Flash",
  80. release_date: "2026-07-31",
  81. attachment: false,
  82. reasoning: true,
  83. temperature: true,
  84. tool_call: true,
  85. provider: { shape: "responses" },
  86. limit: { context: 1_000_000, output: 384_000 },
  87. },
  88. },
  89. },
  90. } satisfies Record<string, ModelsDev.Provider>),
  91. refresh: () => Effect.void,
  92. })
  93. yield* ModelsDevPlugin.effect(
  94. host({
  95. catalog: catalogHost(catalog),
  96. integration: integrationHost(integrations),
  97. }),
  98. ).pipe(Effect.provideService(ModelsDev.Service, models))
  99. const providerID = ProviderV2.ID.make("acme")
  100. const base = yield* catalog.model.get(providerID, ModelV2.ID.make("gpt-5.4"))
  101. const fast = yield* catalog.model.get(providerID, ModelV2.ID.make("gpt-5.4-fast"))
  102. const deepseek = yield* catalog.model.get(providerID, ModelV2.ID.make("deepseek-v4-flash"))
  103. expect(base?.variants).toEqual([])
  104. expect(base?.request.body).toEqual({})
  105. expect(fast).toMatchObject({
  106. id: "gpt-5.4-fast",
  107. providerID: "acme",
  108. name: "GPT-5.4 Fast",
  109. api: { id: "gpt-5.4" },
  110. request: {
  111. headers: { "x-mode": "fast" },
  112. body: { service_tier: "priority" },
  113. },
  114. variants: [],
  115. })
  116. expect(fast?.cost).toEqual([
  117. { input: 5, output: 30, cache: { read: 0.5, write: 0 } },
  118. {
  119. tier: { type: "context", size: 272_000 },
  120. input: 3,
  121. output: 18,
  122. cache: { read: 0.25, write: 0 },
  123. },
  124. {
  125. tier: { type: "context", size: 200_000 },
  126. input: 5,
  127. output: 22.5,
  128. cache: { read: 0.5, write: 0 },
  129. },
  130. ])
  131. expect(deepseek?.api).toMatchObject({
  132. id: "deepseek-v4-flash",
  133. type: "aisdk",
  134. package: "@ai-sdk/openai",
  135. url: "https://api.acme.test/v1",
  136. })
  137. }),
  138. )
  139. it.effect("registers key methods for providers with environment variables", () =>
  140. Effect.acquireUseRelease(
  141. Effect.sync(() => {
  142. const previous = {
  143. path: Flag.OPENCODE_MODELS_PATH,
  144. disabled: Flag.OPENCODE_DISABLE_MODELS_FETCH,
  145. }
  146. Flag.OPENCODE_MODELS_PATH = path.join(import.meta.dir, "fixtures", "models-dev.json")
  147. Flag.OPENCODE_DISABLE_MODELS_FETCH = true
  148. return previous
  149. }),
  150. () =>
  151. Effect.gen(function* () {
  152. const integrations = yield* Integration.Service
  153. const catalog = yield* Catalog.Service
  154. yield* ModelsDevPlugin.effect(
  155. host({
  156. catalog: catalogHost(catalog),
  157. integration: integrationHost(integrations),
  158. }),
  159. )
  160. expect(yield* integrations.list()).toEqual([
  161. new Integration.Info({
  162. id: Integration.ID.make("acme"),
  163. name: "Acme",
  164. methods: [
  165. { type: "key" },
  166. {
  167. type: "env",
  168. names: ["ACME_API_KEY"],
  169. },
  170. ],
  171. connections: [],
  172. }),
  173. ])
  174. }).pipe(Effect.provide(AppNodeBuilder.build(ModelsDev.node))),
  175. (previous) =>
  176. Effect.sync(() => {
  177. Flag.OPENCODE_MODELS_PATH = previous.path
  178. Flag.OPENCODE_DISABLE_MODELS_FETCH = previous.disabled
  179. }),
  180. ),
  181. )
  182. })