models-dev.test.ts 5.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170
  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. },
  78. },
  79. } satisfies Record<string, ModelsDev.Provider>),
  80. refresh: () => Effect.void,
  81. })
  82. yield* ModelsDevPlugin.effect(
  83. host({
  84. catalog: catalogHost(catalog),
  85. integration: integrationHost(integrations),
  86. }),
  87. ).pipe(Effect.provideService(ModelsDev.Service, models))
  88. const providerID = ProviderV2.ID.make("acme")
  89. const base = yield* catalog.model.get(providerID, ModelV2.ID.make("gpt-5.4"))
  90. const fast = yield* catalog.model.get(providerID, ModelV2.ID.make("gpt-5.4-fast"))
  91. expect(base?.variants).toEqual([])
  92. expect(base?.request.body).toEqual({})
  93. expect(fast).toMatchObject({
  94. id: "gpt-5.4-fast",
  95. providerID: "acme",
  96. name: "GPT-5.4 Fast",
  97. api: { id: "gpt-5.4" },
  98. request: {
  99. headers: { "x-mode": "fast" },
  100. body: { service_tier: "priority" },
  101. },
  102. variants: [],
  103. })
  104. expect(fast?.cost).toEqual([
  105. { input: 5, output: 30, cache: { read: 0.5, write: 0 } },
  106. {
  107. tier: { type: "context", size: 272_000 },
  108. input: 3,
  109. output: 18,
  110. cache: { read: 0.25, write: 0 },
  111. },
  112. {
  113. tier: { type: "context", size: 200_000 },
  114. input: 5,
  115. output: 22.5,
  116. cache: { read: 0.5, write: 0 },
  117. },
  118. ])
  119. }),
  120. )
  121. it.effect("registers key methods for providers with environment variables", () =>
  122. Effect.acquireUseRelease(
  123. Effect.sync(() => {
  124. const previous = {
  125. path: Flag.OPENCODE_MODELS_PATH,
  126. disabled: Flag.OPENCODE_DISABLE_MODELS_FETCH,
  127. }
  128. Flag.OPENCODE_MODELS_PATH = path.join(import.meta.dir, "fixtures", "models-dev.json")
  129. Flag.OPENCODE_DISABLE_MODELS_FETCH = true
  130. return previous
  131. }),
  132. () =>
  133. Effect.gen(function* () {
  134. const integrations = yield* Integration.Service
  135. const catalog = yield* Catalog.Service
  136. yield* ModelsDevPlugin.effect(
  137. host({
  138. catalog: catalogHost(catalog),
  139. integration: integrationHost(integrations),
  140. }),
  141. )
  142. expect(yield* integrations.list()).toEqual([
  143. new Integration.Info({
  144. id: Integration.ID.make("acme"),
  145. name: "Acme",
  146. methods: [
  147. { type: "key" },
  148. {
  149. type: "env",
  150. names: ["ACME_API_KEY"],
  151. },
  152. ],
  153. connections: [],
  154. }),
  155. ])
  156. }).pipe(Effect.provide(AppNodeBuilder.build(ModelsDev.node))),
  157. (previous) =>
  158. Effect.sync(() => {
  159. Flag.OPENCODE_MODELS_PATH = previous.path
  160. Flag.OPENCODE_DISABLE_MODELS_FETCH = previous.disabled
  161. }),
  162. ),
  163. )
  164. })