session-runner-model.test.ts 7.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213
  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: { store: false, apiKey: "secret" },
  31. },
  32. variants,
  33. time: { released: DateTime.makeUnsafe(0) },
  34. cost: [],
  35. status: "active",
  36. enabled: true,
  37. limit: { context: 100, output: 20 },
  38. })
  39. const provider = (api: ProviderV2.Info["api"]) =>
  40. new ProviderV2.Info({
  41. id: ProviderV2.ID.make("test-provider"),
  42. name: "Test provider",
  43. enabled: { via: "env", name: "TEST_PROVIDER_API_KEY" },
  44. env: ["TEST_PROVIDER_API_KEY"],
  45. api,
  46. request: { headers: {}, body: {} },
  47. })
  48. describe("SessionRunnerModel", () => {
  49. it.effect("maps catalog OpenAI AI SDK models into native Responses routes", () =>
  50. Effect.gen(function* () {
  51. const resolved = yield* SessionRunnerModel.fromCatalogModel(
  52. model({ type: "aisdk", package: "@ai-sdk/openai", url: "https://openai.example/v1" }),
  53. )
  54. expect(resolved).toMatchObject({ id: "api-test-model", provider: "test-provider" })
  55. expect(resolved.route).toMatchObject({
  56. id: "openai-responses",
  57. endpoint: { baseURL: "https://openai.example/v1" },
  58. defaults: {
  59. headers: { "x-test": "header" },
  60. limits: { context: 100, output: 20 },
  61. http: { body: { store: false } },
  62. },
  63. })
  64. }),
  65. )
  66. it.effect("keeps catalog apiKey credentials out of provider JSON", () =>
  67. Effect.gen(function* () {
  68. const resolved = yield* SessionRunnerModel.fromCatalogModel(
  69. model({ type: "aisdk", package: "@ai-sdk/openai", url: "https://openai.example/v1" }),
  70. )
  71. const prepared = yield* LLMClient.prepare(LLM.request({ model: resolved, prompt: "Hello" }))
  72. expect(JSON.stringify(prepared.body)).not.toContain("apiKey")
  73. expect(JSON.stringify(prepared.body)).not.toContain("secret")
  74. }),
  75. )
  76. it.effect("uses merged API settings for OpenAI-compatible auth and request defaults", () =>
  77. Effect.gen(function* () {
  78. const resolved = yield* SessionRunnerModel.fromCatalogModel(
  79. new ModelV2.Info({
  80. ...model({
  81. type: "aisdk",
  82. package: "@ai-sdk/openai-compatible",
  83. url: "https://compatible.example/v1",
  84. settings: { apiKey: "settings-secret", compatibility: "strict" },
  85. }),
  86. request: { headers: {}, body: {} },
  87. }),
  88. )
  89. const request = LLM.request({ model: resolved, prompt: "Hello" })
  90. const headers = yield* resolved.route.auth.apply({
  91. request,
  92. method: "POST",
  93. url: "https://compatible.example/v1/chat/completions",
  94. body: "{}",
  95. headers: Headers.empty,
  96. })
  97. expect(headers.authorization).toBe("Bearer settings-secret")
  98. expect(resolved.route.defaults.http?.body).toEqual({})
  99. }),
  100. )
  101. it.effect("applies the selected Session variant to request options", () =>
  102. Effect.gen(function* () {
  103. const catalog = model({ type: "aisdk", package: "@ai-sdk/openai", url: "https://openai.example/v1" }, [
  104. {
  105. id: ModelV2.VariantID.make("high"),
  106. headers: { "x-variant": "high" },
  107. body: { reasoningEffort: "high" },
  108. },
  109. ])
  110. const session = SessionV2.Info.make({
  111. id: SessionV2.ID.make("ses_model_variant"),
  112. projectID: ProjectV2.ID.global,
  113. title: "test",
  114. model: {
  115. id: catalog.id,
  116. providerID: catalog.providerID,
  117. variant: ModelV2.VariantID.make("high"),
  118. },
  119. cost: 0,
  120. tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
  121. time: { created: DateTime.makeUnsafe(0), updated: DateTime.makeUnsafe(0) },
  122. location: { directory: AbsolutePath.make("/project") },
  123. })
  124. const resolved = yield* SessionRunnerModel.resolve(session, catalog)
  125. expect(resolved.route.defaults).toMatchObject({
  126. headers: { "x-test": "header", "x-variant": "high" },
  127. http: { body: { store: false, reasoningEffort: "high" } },
  128. })
  129. }),
  130. )
  131. it.effect("maps catalog Anthropic AI SDK models into native routes", () =>
  132. Effect.gen(function* () {
  133. const resolved = yield* SessionRunnerModel.fromCatalogModel(
  134. model({ type: "aisdk", package: "@ai-sdk/anthropic", url: "https://anthropic.example/v1" }),
  135. )
  136. expect(resolved.route).toMatchObject({
  137. id: "anthropic-messages",
  138. endpoint: { baseURL: "https://anthropic.example/v1" },
  139. })
  140. }),
  141. )
  142. it.effect("preserves environment-backed bearer auth", () =>
  143. Effect.gen(function* () {
  144. const resolved = yield* SessionRunnerModel.fromCatalogModel(
  145. new ModelV2.Info({
  146. ...model({ type: "aisdk", package: "@ai-sdk/openai", url: "https://openai.example/v1" }),
  147. request: { headers: {}, body: {} },
  148. }),
  149. provider({ type: "aisdk", package: "@ai-sdk/openai", url: "https://openai.example/v1" }),
  150. )
  151. const request = LLM.request({ model: resolved, prompt: "Hello" })
  152. const headers = yield* resolved.route.auth
  153. .apply({
  154. request,
  155. method: "POST",
  156. url: "https://openai.example/v1/responses",
  157. body: "{}",
  158. headers: Headers.empty,
  159. })
  160. .pipe(
  161. Effect.provide(ConfigProvider.layer(ConfigProvider.fromEnv({ env: { TEST_PROVIDER_API_KEY: "secret" } }))),
  162. )
  163. expect(headers.authorization).toBe("Bearer secret")
  164. }),
  165. )
  166. it.effect("rejects catalog APIs without a native route", () =>
  167. Effect.gen(function* () {
  168. const failure = yield* SessionRunnerModel.fromCatalogModel(
  169. model({ type: "aisdk", package: "@ai-sdk/google", url: "https://google.example/v1" }),
  170. ).pipe(Effect.flip)
  171. expect(failure).toMatchObject({
  172. _tag: "SessionRunnerModel.UnsupportedApiError",
  173. providerID: "test-provider",
  174. modelID: "test-model",
  175. api: "aisdk:@ai-sdk/google",
  176. })
  177. }),
  178. )
  179. it.effect("reports whether a catalog model has a supported native route", () =>
  180. Effect.sync(() => {
  181. expect(
  182. SessionRunnerModel.supported(
  183. model({ type: "aisdk", package: "@ai-sdk/openai", url: "https://openai.example/v1" }),
  184. ),
  185. ).toBe(true)
  186. expect(
  187. SessionRunnerModel.supported(
  188. model({ type: "aisdk", package: "@ai-sdk/google", url: "https://google.example/v1" }),
  189. ),
  190. ).toBe(false)
  191. expect(SessionRunnerModel.supported(model({ type: "native", settings: {} }))).toBe(false)
  192. }),
  193. )
  194. })