session-runner-model.test.ts 12 KB

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