session-runner-model.test.ts 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299
  1. import { describe, expect } from "bun:test"
  2. import { LLM } from "@opencode-ai/llm"
  3. import { LLMClient } from "@opencode-ai/llm/route"
  4. import { ConfigProvider, DateTime, Effect } from "effect"
  5. import { Headers } from "effect/unstable/http"
  6. import { ModelV2 } from "@opencode-ai/core/model"
  7. import { ProviderV2 } from "@opencode-ai/core/provider"
  8. import { ProjectV2 } from "@opencode-ai/core/project"
  9. import { SessionRunnerModel } from "@opencode-ai/core/session/runner/model"
  10. import { SessionV2 } from "@opencode-ai/core/session"
  11. import { AbsolutePath } from "@opencode-ai/core/schema"
  12. import { it } from "./lib/effect"
  13. type Api =
  14. | {
  15. readonly type: "aisdk"
  16. readonly package: string
  17. readonly url?: string
  18. readonly settings?: Record<string, unknown>
  19. }
  20. | { readonly type: "native"; readonly url?: string; readonly settings: Record<string, unknown> }
  21. const model = (api: Api, variants: ModelV2.Info["variants"] = []) =>
  22. new ModelV2.Info({
  23. id: ModelV2.ID.make("test-model"),
  24. providerID: ProviderV2.ID.make("test-provider"),
  25. name: "Test model",
  26. api: { id: ModelV2.ID.make("api-test-model"), ...api },
  27. capabilities: { tools: true, input: ["text"], output: ["text"] },
  28. request: {
  29. headers: { "x-test": "header" },
  30. body: { apiKey: "secret", custom_extension: { enabled: true } },
  31. generation: { temperature: 0.7 },
  32. options: { store: false, serviceTier: "priority" },
  33. },
  34. variants,
  35. time: { released: DateTime.makeUnsafe(0) },
  36. cost: [],
  37. status: "active",
  38. enabled: true,
  39. limit: { context: 100, output: 20 },
  40. })
  41. const provider = (api: ProviderV2.Info["api"]) =>
  42. new ProviderV2.Info({
  43. id: ProviderV2.ID.make("test-provider"),
  44. name: "Test provider",
  45. enabled: { via: "env", name: "TEST_PROVIDER_API_KEY" },
  46. env: ["TEST_PROVIDER_API_KEY"],
  47. api,
  48. request: { headers: {}, body: {} },
  49. })
  50. describe("SessionRunnerModel", () => {
  51. it.effect("maps catalog OpenAI AI SDK models into native Responses routes", () =>
  52. Effect.gen(function* () {
  53. const resolved = yield* SessionRunnerModel.fromCatalogModel(
  54. model({ type: "aisdk", package: "@ai-sdk/openai", url: "https://openai.example/v1" }),
  55. )
  56. expect(resolved).toMatchObject({ id: "api-test-model", provider: "test-provider" })
  57. expect(resolved.route).toMatchObject({
  58. id: "openai-responses",
  59. endpoint: { baseURL: "https://openai.example/v1" },
  60. defaults: {
  61. headers: { "x-test": "header" },
  62. limits: { context: 100, output: 20 },
  63. generation: { temperature: 0.7 },
  64. providerOptions: { openai: { store: false, serviceTier: "priority" } },
  65. http: { body: { custom_extension: { enabled: true } } },
  66. },
  67. })
  68. }),
  69. )
  70. it.effect("keeps catalog apiKey credentials out of provider JSON", () =>
  71. Effect.gen(function* () {
  72. const resolved = yield* SessionRunnerModel.fromCatalogModel(
  73. model({ type: "aisdk", package: "@ai-sdk/openai", url: "https://openai.example/v1" }),
  74. )
  75. const prepared = yield* LLMClient.prepare(LLM.request({ model: resolved, prompt: "Hello" }))
  76. expect(JSON.stringify(prepared.body)).not.toContain("apiKey")
  77. expect(JSON.stringify(prepared.body)).not.toContain("secret")
  78. }),
  79. )
  80. it.effect("uses merged API settings for OpenAI-compatible auth and request defaults", () =>
  81. Effect.gen(function* () {
  82. const resolved = yield* SessionRunnerModel.fromCatalogModel(
  83. new ModelV2.Info({
  84. ...model({
  85. type: "aisdk",
  86. package: "@ai-sdk/openai-compatible",
  87. url: "https://compatible.example/v1",
  88. settings: { apiKey: "settings-secret", compatibility: "strict" },
  89. }),
  90. request: { headers: {}, body: {}, generation: {}, options: {} },
  91. }),
  92. )
  93. const request = LLM.request({ model: resolved, prompt: "Hello" })
  94. const headers = yield* resolved.route.auth.apply({
  95. request,
  96. method: "POST",
  97. url: "https://compatible.example/v1/chat/completions",
  98. body: "{}",
  99. headers: Headers.empty,
  100. })
  101. expect(headers.authorization).toBe("Bearer settings-secret")
  102. expect(resolved.route.defaults.http?.body).toEqual({})
  103. }),
  104. )
  105. it.effect("lowers selected OpenAI Session variants into Responses options", () =>
  106. Effect.gen(function* () {
  107. const base = model({ type: "aisdk", package: "@ai-sdk/openai", url: "https://openai.example/v1" }, [
  108. {
  109. id: ModelV2.VariantID.make("high"),
  110. headers: { "x-variant": "high" },
  111. body: {},
  112. generation: { temperature: 0.2 },
  113. options: { reasoningEffort: "high" },
  114. },
  115. ])
  116. const catalog = new ModelV2.Info({
  117. ...base,
  118. request: { ...base.request, options: { ...base.request.options, reasoningEffort: "medium" } },
  119. })
  120. const session = SessionV2.Info.make({
  121. id: SessionV2.ID.make("ses_model_variant"),
  122. projectID: ProjectV2.ID.global,
  123. title: "test",
  124. model: {
  125. id: catalog.id,
  126. providerID: catalog.providerID,
  127. variant: ModelV2.VariantID.make("high"),
  128. },
  129. cost: 0,
  130. tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
  131. time: { created: DateTime.makeUnsafe(0), updated: DateTime.makeUnsafe(0) },
  132. location: { directory: AbsolutePath.make("/project") },
  133. })
  134. const resolved = yield* SessionRunnerModel.resolve(session, catalog)
  135. const prepared = yield* LLMClient.prepare(LLM.request({ model: resolved, prompt: "Hello" }))
  136. expect(resolved.route.defaults.headers).toMatchObject({ "x-test": "header", "x-variant": "high" })
  137. expect(resolved.route.defaults.http?.body).toEqual({ custom_extension: { enabled: true } })
  138. expect(prepared.body).toMatchObject({
  139. store: false,
  140. service_tier: "priority",
  141. temperature: 0.2,
  142. reasoning: { effort: "high" },
  143. })
  144. expect(prepared.body).not.toHaveProperty("reasoningEffort")
  145. }),
  146. )
  147. it.effect("lowers selected OpenAI-compatible Session variants into Chat options", () =>
  148. Effect.gen(function* () {
  149. const catalog = model(
  150. { type: "aisdk", package: "@ai-sdk/openai-compatible", url: "https://compatible.example/v1" },
  151. [
  152. {
  153. id: ModelV2.VariantID.make("high"),
  154. headers: {},
  155. body: {},
  156. generation: {},
  157. options: { reasoningEffort: "high" },
  158. },
  159. ],
  160. )
  161. const session = SessionV2.Info.make({
  162. id: SessionV2.ID.make("ses_compatible_variant"),
  163. projectID: ProjectV2.ID.global,
  164. title: "test",
  165. model: { id: catalog.id, providerID: catalog.providerID, variant: ModelV2.VariantID.make("high") },
  166. cost: 0,
  167. tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
  168. time: { created: DateTime.makeUnsafe(0), updated: DateTime.makeUnsafe(0) },
  169. location: { directory: AbsolutePath.make("/project") },
  170. })
  171. const resolved = yield* SessionRunnerModel.resolve(session, catalog)
  172. const prepared = yield* LLMClient.prepare(LLM.request({ model: resolved, prompt: "Hello" }))
  173. expect(resolved.route.defaults.http?.body).toEqual({ custom_extension: { enabled: true } })
  174. expect(prepared.body).toMatchObject({
  175. store: false,
  176. reasoning_effort: "high",
  177. })
  178. expect(prepared.body).not.toHaveProperty("reasoningEffort")
  179. }),
  180. )
  181. it.effect("lowers selected Anthropic Session variants into Messages options", () =>
  182. Effect.gen(function* () {
  183. const catalog = model({ type: "aisdk", package: "@ai-sdk/anthropic", url: "https://anthropic.example/v1" }, [
  184. {
  185. id: ModelV2.VariantID.make("high"),
  186. headers: {},
  187. body: {},
  188. generation: {},
  189. options: { thinking: { type: "enabled", budgetTokens: 12000 } },
  190. },
  191. ])
  192. const session = SessionV2.Info.make({
  193. id: SessionV2.ID.make("ses_anthropic_variant"),
  194. projectID: ProjectV2.ID.global,
  195. title: "test",
  196. model: { id: catalog.id, providerID: catalog.providerID, variant: ModelV2.VariantID.make("high") },
  197. cost: 0,
  198. tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
  199. time: { created: DateTime.makeUnsafe(0), updated: DateTime.makeUnsafe(0) },
  200. location: { directory: AbsolutePath.make("/project") },
  201. })
  202. const resolved = yield* SessionRunnerModel.resolve(session, catalog)
  203. const prepared = yield* LLMClient.prepare(LLM.request({ model: resolved, prompt: "Hello" }))
  204. expect(resolved.route.defaults.http?.body).toEqual({ custom_extension: { enabled: true } })
  205. expect(prepared.body).toMatchObject({
  206. thinking: { type: "enabled", budget_tokens: 12000 },
  207. })
  208. expect(JSON.stringify(prepared.body)).not.toContain("budgetTokens")
  209. }),
  210. )
  211. it.effect("maps catalog Anthropic AI SDK models into native routes", () =>
  212. Effect.gen(function* () {
  213. const resolved = yield* SessionRunnerModel.fromCatalogModel(
  214. model({ type: "aisdk", package: "@ai-sdk/anthropic", url: "https://anthropic.example/v1" }),
  215. )
  216. expect(resolved.route).toMatchObject({
  217. id: "anthropic-messages",
  218. endpoint: { baseURL: "https://anthropic.example/v1" },
  219. })
  220. }),
  221. )
  222. it.effect("preserves environment-backed bearer auth", () =>
  223. Effect.gen(function* () {
  224. const resolved = yield* SessionRunnerModel.fromCatalogModel(
  225. new ModelV2.Info({
  226. ...model({ type: "aisdk", package: "@ai-sdk/openai", url: "https://openai.example/v1" }),
  227. request: { headers: {}, body: {}, generation: {}, options: {} },
  228. }),
  229. provider({ type: "aisdk", package: "@ai-sdk/openai", url: "https://openai.example/v1" }),
  230. )
  231. const request = LLM.request({ model: resolved, prompt: "Hello" })
  232. const headers = yield* resolved.route.auth
  233. .apply({
  234. request,
  235. method: "POST",
  236. url: "https://openai.example/v1/responses",
  237. body: "{}",
  238. headers: Headers.empty,
  239. })
  240. .pipe(
  241. Effect.provide(ConfigProvider.layer(ConfigProvider.fromEnv({ env: { TEST_PROVIDER_API_KEY: "secret" } }))),
  242. )
  243. expect(headers.authorization).toBe("Bearer secret")
  244. }),
  245. )
  246. it.effect("rejects catalog APIs without a native route", () =>
  247. Effect.gen(function* () {
  248. const failure = yield* SessionRunnerModel.fromCatalogModel(
  249. model({ type: "aisdk", package: "@ai-sdk/google", url: "https://google.example/v1" }),
  250. ).pipe(Effect.flip)
  251. expect(failure).toMatchObject({
  252. _tag: "SessionRunnerModel.UnsupportedApiError",
  253. providerID: "test-provider",
  254. modelID: "test-model",
  255. api: "aisdk:@ai-sdk/google",
  256. })
  257. }),
  258. )
  259. it.effect("reports whether a catalog model has a supported native route", () =>
  260. Effect.sync(() => {
  261. expect(
  262. SessionRunnerModel.supported(
  263. model({ type: "aisdk", package: "@ai-sdk/openai", url: "https://openai.example/v1" }),
  264. ),
  265. ).toBe(true)
  266. expect(
  267. SessionRunnerModel.supported(
  268. model({ type: "aisdk", package: "@ai-sdk/google", url: "https://google.example/v1" }),
  269. ),
  270. ).toBe(false)
  271. expect(SessionRunnerModel.supported(model({ type: "native", settings: {} }))).toBe(false)
  272. }),
  273. )
  274. })