session-runner-model.test.ts 13 KB

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