models-dev.test.ts 21 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554
  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/util/effect/layer-node"
  9. import { Bus } from "@opencode-ai/core/bus"
  10. import { Location } from "@opencode-ai/core/location"
  11. import { Model } 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 { Provider } 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, Bus.node]), [
  24. [Location.node, locationLayer],
  25. ])
  26. const it = testEffect(layer)
  27. const models = (file: string) =>
  28. AppNodeBuilder.build(ModelsDev.node, [[ModelsDev.node, ModelsDev.configured({ file, fetch: false })]])
  29. describe("ModelsDevPlugin", () => {
  30. it.effect("projects normalized models.dev snapshots into the catalog", () =>
  31. Effect.gen(function* () {
  32. const integrations = yield* Integration.Service
  33. const catalog = yield* Catalog.Service
  34. const providerID = Provider.ID.make("acme")
  35. const modelID = Model.ID.make("gpt-5.4")
  36. const models = ModelsDev.Service.of({
  37. get: () =>
  38. Effect.succeed([
  39. {
  40. info: {
  41. id: providerID,
  42. name: "Acme",
  43. package: Provider.aisdk("@ai-sdk/openai-compatible"),
  44. settings: { baseURL: "https://api.acme.test/v1" },
  45. },
  46. environment: [],
  47. models: [
  48. {
  49. id: modelID,
  50. modelID,
  51. providerID,
  52. name: "GPT-5.4",
  53. family: Model.Family.make("gpt"),
  54. capabilities: { tools: true, input: [], output: [] },
  55. variants: [],
  56. time: { released: Date.parse("2026-01-01") },
  57. cost: [
  58. {
  59. input: Money.USDPerMillionTokens.make(2.5),
  60. output: Money.USDPerMillionTokens.make(15),
  61. cache: {
  62. read: Money.USDPerMillionTokens.zero,
  63. write: Money.USDPerMillionTokens.zero,
  64. },
  65. },
  66. {
  67. tier: { type: "context", size: 272_000 },
  68. input: Money.USDPerMillionTokens.make(3),
  69. output: Money.USDPerMillionTokens.make(18),
  70. cache: {
  71. read: Money.USDPerMillionTokens.make(0.25),
  72. write: Money.USDPerMillionTokens.zero,
  73. },
  74. },
  75. {
  76. tier: { type: "context", size: 200_000 },
  77. input: Money.USDPerMillionTokens.make(5),
  78. output: Money.USDPerMillionTokens.make(22.5),
  79. cache: {
  80. read: Money.USDPerMillionTokens.make(0.5),
  81. write: Money.USDPerMillionTokens.zero,
  82. },
  83. },
  84. ],
  85. status: "active",
  86. enabled: true,
  87. limit: { context: 1_050_000, input: 922_000, output: 128_000 },
  88. },
  89. {
  90. id: Model.ID.make("gpt-5.4-fast"),
  91. modelID,
  92. providerID,
  93. name: "GPT-5.4 Fast",
  94. family: Model.Family.make("gpt"),
  95. package: Provider.aisdk("@ai-sdk/openai-compatible"),
  96. settings: { baseURL: "https://api.acme.test/v1" },
  97. headers: { "x-mode": "fast" },
  98. body: { service_tier: "priority" },
  99. capabilities: { tools: true, input: [], output: [] },
  100. variants: [],
  101. time: { released: Date.parse("2026-01-01") },
  102. cost: [
  103. {
  104. input: Money.USDPerMillionTokens.make(5),
  105. output: Money.USDPerMillionTokens.make(30),
  106. cache: {
  107. read: Money.USDPerMillionTokens.make(0.5),
  108. write: Money.USDPerMillionTokens.zero,
  109. },
  110. },
  111. {
  112. tier: { type: "context", size: 272_000 },
  113. input: Money.USDPerMillionTokens.make(3),
  114. output: Money.USDPerMillionTokens.make(18),
  115. cache: {
  116. read: Money.USDPerMillionTokens.make(0.25),
  117. write: Money.USDPerMillionTokens.zero,
  118. },
  119. },
  120. {
  121. tier: { type: "context", size: 200_000 },
  122. input: Money.USDPerMillionTokens.make(5),
  123. output: Money.USDPerMillionTokens.make(22.5),
  124. cache: {
  125. read: Money.USDPerMillionTokens.make(0.5),
  126. write: Money.USDPerMillionTokens.zero,
  127. },
  128. },
  129. ],
  130. status: "active",
  131. enabled: true,
  132. limit: { context: 1_050_000, input: 922_000, output: 128_000 },
  133. },
  134. ],
  135. },
  136. ] satisfies readonly ModelsDev.Snapshot[]),
  137. refresh: () => Effect.void,
  138. })
  139. yield* ModelsDevPlugin.effect(
  140. host({
  141. catalog: catalogHost(catalog),
  142. integration: integrationHost(integrations),
  143. }),
  144. ).pipe(Effect.provideService(ModelsDev.Service, models))
  145. const base = yield* catalog.model.get(providerID, Model.ID.make("gpt-5.4"))
  146. const fast = yield* catalog.model.get(providerID, Model.ID.make("gpt-5.4-fast"))
  147. expect(base?.variants).toEqual([])
  148. expect(base?.body).toBeUndefined()
  149. expect(fast).toMatchObject({
  150. id: "gpt-5.4-fast",
  151. modelID: "gpt-5.4",
  152. providerID: "acme",
  153. name: "GPT-5.4 Fast",
  154. package: Provider.aisdk("@ai-sdk/openai-compatible"),
  155. settings: { baseURL: "https://api.acme.test/v1" },
  156. headers: { "x-mode": "fast" },
  157. body: { service_tier: "priority" },
  158. variants: [],
  159. })
  160. expect(fast?.cost).toEqual([
  161. {
  162. input: Money.USDPerMillionTokens.make(5),
  163. output: Money.USDPerMillionTokens.make(30),
  164. cache: {
  165. read: Money.USDPerMillionTokens.make(0.5),
  166. write: Money.USDPerMillionTokens.zero,
  167. },
  168. },
  169. {
  170. tier: { type: "context", size: 272_000 },
  171. input: Money.USDPerMillionTokens.make(3),
  172. output: Money.USDPerMillionTokens.make(18),
  173. cache: {
  174. read: Money.USDPerMillionTokens.make(0.25),
  175. write: Money.USDPerMillionTokens.zero,
  176. },
  177. },
  178. {
  179. tier: { type: "context", size: 200_000 },
  180. input: Money.USDPerMillionTokens.make(5),
  181. output: Money.USDPerMillionTokens.make(22.5),
  182. cache: {
  183. read: Money.USDPerMillionTokens.make(0.5),
  184. write: Money.USDPerMillionTokens.zero,
  185. },
  186. },
  187. ])
  188. }),
  189. )
  190. it.effect("registers key methods for providers with environment variables", () =>
  191. Effect.gen(function* () {
  192. const integrations = yield* Integration.Service
  193. const catalog = yield* Catalog.Service
  194. yield* ModelsDevPlugin.effect(
  195. host({
  196. catalog: catalogHost(catalog),
  197. integration: integrationHost(integrations),
  198. }),
  199. )
  200. expect(yield* integrations.list()).toEqual([
  201. Integration.Info.make({
  202. id: Integration.ID.make("acme"),
  203. name: "Acme",
  204. methods: [
  205. { type: "key" },
  206. {
  207. type: "env",
  208. names: ["ACME_API_KEY"],
  209. },
  210. ],
  211. connections: [],
  212. }),
  213. ])
  214. }).pipe(Effect.provide(models(path.join(import.meta.dir, "fixtures", "models-dev.json")))),
  215. )
  216. it.effect("converts reasoning options into settings variants", () =>
  217. Effect.gen(function* () {
  218. const catalog = yield* Catalog.Service
  219. const integrations = yield* Integration.Service
  220. yield* ModelsDevPlugin.effect(
  221. host({
  222. catalog: catalogHost(catalog),
  223. integration: integrationHost(integrations),
  224. }),
  225. )
  226. const model = yield* catalog.model.get(Provider.ID.openai, Model.ID.make("gpt-reasoning"))
  227. expect(model?.variants?.map((variant) => variant.id)).toEqual([
  228. Model.VariantID.make("low"),
  229. Model.VariantID.make("high"),
  230. ])
  231. expect(model?.variants).toContainEqual({
  232. id: Model.VariantID.make("low"),
  233. settings: {
  234. reasoningEffort: "low",
  235. reasoningSummary: "auto",
  236. include: ["reasoning.encrypted_content"],
  237. },
  238. })
  239. expect(model?.variants).toContainEqual({
  240. id: Model.VariantID.make("high"),
  241. settings: {
  242. reasoningEffort: "high",
  243. reasoningSummary: "auto",
  244. include: ["reasoning.encrypted_content"],
  245. },
  246. })
  247. const mode = yield* catalog.model.get(Provider.ID.openai, Model.ID.make("gpt-reasoning-high"))
  248. expect(mode).toMatchObject({
  249. id: "gpt-reasoning-high",
  250. name: "GPT Reasoning High",
  251. headers: { "x-mode": "high" },
  252. body: { service_tier: "priority" },
  253. })
  254. expect(mode?.variants?.map((variant) => variant.id)).toEqual([
  255. Model.VariantID.make("low"),
  256. Model.VariantID.make("high"),
  257. ])
  258. const pro = yield* catalog.model.get(Provider.ID.openai, Model.ID.make("gpt-reasoning-pro"))
  259. expect(pro).toMatchObject({
  260. id: "gpt-reasoning-pro",
  261. body: { reasoning: { mode: "pro" } },
  262. })
  263. const budgetModel = yield* catalog.model.get(Provider.ID.anthropic, Model.ID.make("claude-budget"))
  264. expect(budgetModel?.variants).toContainEqual({
  265. id: Model.VariantID.make("high"),
  266. settings: { thinking: { type: "enabled", budgetTokens: 16000 } },
  267. })
  268. expect(budgetModel?.variants).toContainEqual({
  269. id: Model.VariantID.make("max"),
  270. settings: { thinking: { type: "enabled", budgetTokens: 31999 } },
  271. })
  272. const anthropicEffortModel = yield* catalog.model.get(Provider.ID.anthropic, Model.ID.make("claude-opus-4.7"))
  273. expect(anthropicEffortModel?.variants).toEqual([
  274. { id: Model.VariantID.make("none"), settings: { thinking: { type: "disabled" } } },
  275. {
  276. id: Model.VariantID.make("low"),
  277. settings: { thinking: { type: "adaptive", display: "summarized" }, effort: "low" },
  278. },
  279. ])
  280. const anthropicToggleModel = yield* catalog.model.get(Provider.ID.anthropic, Model.ID.make("claude-toggle"))
  281. expect(anthropicToggleModel?.variants).toEqual([
  282. { id: Model.VariantID.make("none"), settings: { thinking: { type: "disabled" } } },
  283. {
  284. id: Model.VariantID.make("thinking"),
  285. settings: { thinking: { type: "adaptive", display: "summarized" } },
  286. },
  287. ])
  288. const opus45 = yield* catalog.model.get(Provider.ID.anthropic, Model.ID.make("claude-opus-4-5"))
  289. expect(opus45?.variants).toEqual([
  290. { id: Model.VariantID.make("low"), settings: { effort: "low" } },
  291. { id: Model.VariantID.make("high"), settings: { effort: "high" } },
  292. ])
  293. const grok = yield* catalog.model.get(Provider.ID.make("xai"), Model.ID.make("grok-4.5"))
  294. expect(grok?.variants).toEqual(
  295. ["low", "medium", "high"].map((id) => ({
  296. id: Model.VariantID.make(id),
  297. settings: { reasoningEffort: id },
  298. })),
  299. )
  300. const minimax = yield* catalog.model.get(Provider.ID.make("opencode-go"), Model.ID.make("minimax-m3"))
  301. expect(minimax?.variants).toEqual([
  302. { id: Model.VariantID.make("none"), settings: { thinking: { type: "disabled" } } },
  303. {
  304. id: Model.VariantID.make("thinking"),
  305. settings: { thinking: { type: "adaptive", display: "summarized" } },
  306. },
  307. ])
  308. const toggle = yield* catalog.model.get(Provider.ID.make("alibaba"), Model.ID.make("toggle-only"))
  309. expect(toggle?.variants).toEqual([
  310. { id: Model.VariantID.make("none"), settings: { enableThinking: false } },
  311. { id: Model.VariantID.make("thinking"), settings: { enableThinking: true } },
  312. ])
  313. const combined = yield* catalog.model.get(Provider.ID.make("alibaba"), Model.ID.make("toggle-budget"))
  314. expect(combined?.variants).toEqual([
  315. { id: Model.VariantID.make("none"), settings: { enableThinking: false } },
  316. {
  317. id: Model.VariantID.make("high"),
  318. settings: { enableThinking: true, thinkingBudget: 8000 },
  319. },
  320. {
  321. id: Model.VariantID.make("max"),
  322. settings: { enableThinking: true, thinkingBudget: 16000 },
  323. },
  324. ])
  325. const gateway = yield* catalog.model.get(Provider.ID.make("vercel"), Model.ID.make("alibaba/qwen-toggle"))
  326. expect(gateway?.variants).toEqual([
  327. { id: Model.VariantID.make("none"), settings: { enableThinking: false } },
  328. {
  329. id: Model.VariantID.make("high"),
  330. settings: { enableThinking: true, thinkingBudget: 8000 },
  331. },
  332. {
  333. id: Model.VariantID.make("max"),
  334. settings: { enableThinking: true, thinkingBudget: 16000 },
  335. },
  336. ])
  337. const gatewayNova = yield* catalog.model.get(Provider.ID.make("vercel"), Model.ID.make("amazon/nova-2-lite"))
  338. expect(gatewayNova?.variants).toEqual([
  339. {
  340. id: Model.VariantID.make("none"),
  341. settings: { additionalModelRequestFields: { reasoningConfig: { type: "disabled" } } },
  342. },
  343. {
  344. id: Model.VariantID.make("low"),
  345. settings: { reasoningConfig: { type: "enabled", maxReasoningEffort: "low" } },
  346. },
  347. {
  348. id: Model.VariantID.make("high"),
  349. settings: { reasoningConfig: { type: "enabled", maxReasoningEffort: "high" } },
  350. },
  351. ])
  352. const gatewayFallback = yield* catalog.model.get(
  353. Provider.ID.make("vercel"),
  354. Model.ID.make("deepseek/deepseek-toggle"),
  355. )
  356. expect(gatewayFallback?.variants).toEqual([
  357. {
  358. id: Model.VariantID.make("none"),
  359. settings: { reasoning: { enabled: false } },
  360. },
  361. {
  362. id: Model.VariantID.make("low"),
  363. settings: { reasoningEffort: "low" },
  364. },
  365. {
  366. id: Model.VariantID.make("high"),
  367. settings: { reasoningEffort: "high" },
  368. },
  369. ])
  370. const openrouter = yield* catalog.model.get(
  371. Provider.ID.make("openrouter"),
  372. Model.ID.make("openrouter-toggle"),
  373. )
  374. expect(openrouter?.variants).toEqual([
  375. { id: Model.VariantID.make("none"), settings: { reasoning: { enabled: false } } },
  376. { id: Model.VariantID.make("thinking"), settings: { reasoning: { enabled: true } } },
  377. ])
  378. const google = yield* catalog.model.get(Provider.ID.make("google"), Model.ID.make("gemini-2.5-flash"))
  379. expect(google?.variants).toEqual([
  380. {
  381. id: Model.VariantID.make("none"),
  382. settings: { thinkingConfig: { includeThoughts: false, thinkingBudget: 0 } },
  383. },
  384. {
  385. id: Model.VariantID.make("high"),
  386. settings: { thinkingConfig: { includeThoughts: true, thinkingBudget: 8000 } },
  387. },
  388. {
  389. id: Model.VariantID.make("max"),
  390. settings: { thinkingConfig: { includeThoughts: true, thinkingBudget: 16000 } },
  391. },
  392. ])
  393. const vertex = yield* catalog.model.get(
  394. Provider.ID.make("google-vertex"),
  395. Model.ID.make("gemini-2.5-flash-lite"),
  396. )
  397. expect(vertex?.variants).toEqual([
  398. {
  399. id: Model.VariantID.make("none"),
  400. settings: { thinkingConfig: { includeThoughts: false, thinkingBudget: 0 } },
  401. },
  402. {
  403. id: Model.VariantID.make("high"),
  404. settings: { thinkingConfig: { includeThoughts: true, thinkingBudget: 8000 } },
  405. },
  406. {
  407. id: Model.VariantID.make("max"),
  408. settings: { thinkingConfig: { includeThoughts: true, thinkingBudget: 16000 } },
  409. },
  410. ])
  411. const bedrock = yield* catalog.model.get(
  412. Provider.ID.make("amazon-bedrock"),
  413. Model.ID.make("amazon.nova-2-lite-v1:0"),
  414. )
  415. expect(bedrock?.variants).toEqual([
  416. {
  417. id: Model.VariantID.make("none"),
  418. settings: { additionalModelRequestFields: { reasoningConfig: { type: "disabled" } } },
  419. },
  420. {
  421. id: Model.VariantID.make("low"),
  422. settings: { reasoningConfig: { type: "enabled", maxReasoningEffort: "low" } },
  423. },
  424. {
  425. id: Model.VariantID.make("high"),
  426. settings: { reasoningConfig: { type: "enabled", maxReasoningEffort: "high" } },
  427. },
  428. ])
  429. const sapGemini = yield* catalog.model.get(Provider.ID.make("sap-ai-core"), Model.ID.make("gemini-2.5-flash"))
  430. expect(sapGemini?.variants).toEqual([
  431. {
  432. id: Model.VariantID.make("none"),
  433. settings: { modelParams: { thinkingConfig: { includeThoughts: false, thinkingBudget: 0 } } },
  434. },
  435. {
  436. id: Model.VariantID.make("high"),
  437. settings: { modelParams: { thinkingConfig: { includeThoughts: true, thinkingBudget: 8000 } } },
  438. },
  439. {
  440. id: Model.VariantID.make("max"),
  441. settings: { modelParams: { thinkingConfig: { includeThoughts: true, thinkingBudget: 16000 } } },
  442. },
  443. ])
  444. const sapNova = yield* catalog.model.get(Provider.ID.make("sap-ai-core"), Model.ID.make("amazon--nova-lite"))
  445. expect(sapNova?.variants).toEqual([
  446. {
  447. id: Model.VariantID.make("none"),
  448. settings: {
  449. modelParams: { additionalModelRequestFields: { thinking: { type: "disabled" } } },
  450. },
  451. },
  452. {
  453. id: Model.VariantID.make("low"),
  454. settings: {
  455. modelParams: { additionalModelRequestFields: { output_config: { effort: "low" } } },
  456. },
  457. },
  458. {
  459. id: Model.VariantID.make("high"),
  460. settings: {
  461. modelParams: { additionalModelRequestFields: { output_config: { effort: "high" } } },
  462. },
  463. },
  464. ])
  465. const sapCohere = yield* catalog.model.get(
  466. Provider.ID.make("sap-ai-core"),
  467. Model.ID.make("cohere--command-a-reasoning"),
  468. )
  469. expect(sapCohere?.variants).toEqual([
  470. {
  471. id: Model.VariantID.make("none"),
  472. settings: { modelParams: { thinking: { type: "disabled" } } },
  473. },
  474. {
  475. id: Model.VariantID.make("low"),
  476. settings: { modelParams: { reasoning_effort: "low" } },
  477. },
  478. {
  479. id: Model.VariantID.make("high"),
  480. settings: { modelParams: { reasoning_effort: "high" } },
  481. },
  482. ])
  483. const sapAnthropicEffort = yield* catalog.model.get(
  484. Provider.ID.make("sap-ai-core"),
  485. Model.ID.make("anthropic--claude-4.7-opus"),
  486. )
  487. expect(sapAnthropicEffort?.variants).toEqual([
  488. {
  489. id: Model.VariantID.make("low"),
  490. settings: {
  491. modelParams: {
  492. additionalModelRequestFields: {
  493. thinking: { type: "adaptive", display: "summarized" },
  494. output_config: { effort: "low" },
  495. },
  496. },
  497. },
  498. },
  499. ])
  500. const sapAnthropicBudget = yield* catalog.model.get(
  501. Provider.ID.make("sap-ai-core"),
  502. Model.ID.make("anthropic--claude-4-sonnet"),
  503. )
  504. expect(sapAnthropicBudget?.variants).toEqual([
  505. {
  506. id: Model.VariantID.make("high"),
  507. settings: {
  508. modelParams: {
  509. additionalModelRequestFields: { thinking: { type: "enabled", budget_tokens: 8000 } },
  510. },
  511. },
  512. },
  513. {
  514. id: Model.VariantID.make("max"),
  515. settings: {
  516. modelParams: {
  517. additionalModelRequestFields: { thinking: { type: "enabled", budget_tokens: 16000 } },
  518. },
  519. },
  520. },
  521. ])
  522. }).pipe(Effect.provide(models(path.join(import.meta.dir, "fixtures", "models-dev-reasoning.json")))),
  523. )
  524. })