session-runner-model.test.ts 13 KB

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