session-runner-model.test.ts 22 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608
  1. import { describe, expect } from "bun:test"
  2. import { LLM, Model } from "@opencode-ai/llm"
  3. import { LLMClient } from "@opencode-ai/llm/route"
  4. import { DateTime, Effect } from "effect"
  5. import { Money } from "@opencode-ai/schema/money"
  6. import { Headers } from "effect/unstable/http"
  7. import { Credential } from "@opencode-ai/core/credential"
  8. import { Integration } from "@opencode-ai/core/integration"
  9. import { ModelV2 } from "@opencode-ai/core/model"
  10. import { ProviderV2 } from "@opencode-ai/core/provider"
  11. import { ProjectV2 } from "@opencode-ai/core/project"
  12. import { SessionRunnerModel } from "@opencode-ai/core/session/runner/model"
  13. import { SessionV2 } from "@opencode-ai/core/session"
  14. import { AbsolutePath } from "@opencode-ai/core/schema"
  15. import { it } from "./lib/effect"
  16. interface ModelOptions {
  17. readonly modelID?: string
  18. readonly settings?: ModelV2.Info["settings"]
  19. readonly headers?: ModelV2.Info["headers"]
  20. readonly body?: ModelV2.Info["body"]
  21. readonly variants?: ModelV2.Info["variants"]
  22. }
  23. const model = (packageName: string | undefined, options: ModelOptions = {}) =>
  24. ModelV2.Info.make({
  25. id: ModelV2.ID.make("test-model"),
  26. modelID: ModelV2.ID.make(options.modelID ?? "api-test-model"),
  27. providerID: ProviderV2.ID.make("test-provider"),
  28. name: "Test model",
  29. package: packageName,
  30. settings: options.settings ?? {},
  31. headers: options.headers ?? { "x-test": "header" },
  32. body: options.body ?? { custom_extension: { enabled: true } },
  33. capabilities: { tools: true, input: ["text"], output: ["text"] },
  34. variants: options.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("uses the API modelID instead of the catalog ID for native OpenAI routes", () =>
  43. Effect.gen(function* () {
  44. const catalog = model(ProviderV2.aisdk("@ai-sdk/openai"), {
  45. settings: { baseURL: "https://openai.example/v1" },
  46. })
  47. const resolved = yield* SessionRunnerModel.fromCatalogModel(catalog)
  48. expect(catalog.id).toBe(ModelV2.ID.make("test-model"))
  49. expect(resolved).toMatchObject({ id: "api-test-model", provider: "test-provider" })
  50. expect(resolved.route).toMatchObject({
  51. id: "openai-responses",
  52. providerMetadataKey: "openai",
  53. endpoint: { baseURL: "https://openai.example/v1" },
  54. defaults: {
  55. headers: { "x-test": "header" },
  56. limits: { context: 100, output: 20 },
  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(ProviderV2.aisdk("@ai-sdk/openai"), {
  66. settings: { apiKey: "secret", baseURL: "https://openai.example/v1" },
  67. }),
  68. )
  69. const prepared = yield* LLMClient.prepare(LLM.request({ model: resolved, prompt: "Hello" }))
  70. expect(JSON.stringify(prepared.body)).not.toContain("apiKey")
  71. expect(JSON.stringify(prepared.body)).not.toContain("secret")
  72. }),
  73. )
  74. it.effect("uses merged API settings for OpenAI-compatible auth and request defaults", () =>
  75. Effect.gen(function* () {
  76. const resolved = yield* SessionRunnerModel.fromCatalogModel(
  77. model(ProviderV2.aisdk("@ai-sdk/openai-compatible"), {
  78. settings: {
  79. apiKey: "settings-secret",
  80. baseURL: "https://compatible.example/v1",
  81. compatibility: "strict",
  82. },
  83. headers: {},
  84. body: {},
  85. }),
  86. )
  87. const request = LLM.request({ model: resolved, prompt: "Hello" })
  88. const headers = yield* resolved.route.auth.apply({
  89. request,
  90. method: "POST",
  91. url: "https://compatible.example/v1/chat/completions",
  92. body: "{}",
  93. headers: Headers.empty,
  94. })
  95. expect(headers.authorization).toBe("Bearer settings-secret")
  96. expect(resolved.route.id).toBe("openai-compatible-chat")
  97. expect(resolved.route.endpoint.baseURL).toBe("https://compatible.example/v1")
  98. expect(resolved.route.defaults.http?.body).toEqual({})
  99. }),
  100. )
  101. it.effect("overlays selected OpenAI Session variant settings and bodies", () =>
  102. Effect.gen(function* () {
  103. const catalog = model(ProviderV2.aisdk("@ai-sdk/openai"), {
  104. settings: { baseURL: "https://openai.example/v1" },
  105. variants: [
  106. {
  107. id: ModelV2.VariantID.make("high"),
  108. settings: { reasoningEffort: "high" },
  109. headers: { "x-variant": "high" },
  110. body: {
  111. store: false,
  112. service_tier: "priority",
  113. temperature: 0.2,
  114. },
  115. },
  116. ],
  117. })
  118. const session = SessionV2.Info.make({
  119. id: SessionV2.ID.make("ses_model_variant"),
  120. projectID: ProjectV2.ID.global,
  121. title: "test",
  122. model: {
  123. id: catalog.id,
  124. providerID: catalog.providerID,
  125. variant: ModelV2.VariantID.make("high"),
  126. },
  127. cost: Money.USD.zero,
  128. tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
  129. time: { created: DateTime.makeUnsafe(0), updated: DateTime.makeUnsafe(0) },
  130. location: { directory: AbsolutePath.make("/project") },
  131. })
  132. const resolved = yield* SessionRunnerModel.resolve(session, catalog)
  133. expect(resolved.route.defaults.headers).toMatchObject({ "x-test": "header", "x-variant": "high" })
  134. expect(resolved.route.defaults.http?.body).toEqual({
  135. custom_extension: { enabled: true },
  136. store: false,
  137. service_tier: "priority",
  138. temperature: 0.2,
  139. })
  140. expect(resolved.route.defaults.providerOptions).toEqual({
  141. openai: { store: false, reasoningEffort: "high" },
  142. })
  143. }),
  144. )
  145. it.effect("overlays selected OpenAI-compatible Session variant bodies", () =>
  146. Effect.gen(function* () {
  147. const catalog = model(ProviderV2.aisdk("@ai-sdk/openai-compatible"), {
  148. settings: { baseURL: "https://compatible.example/v1" },
  149. variants: [
  150. {
  151. id: ModelV2.VariantID.make("high"),
  152. settings: {},
  153. headers: {},
  154. body: { store: false, reasoning_effort: "high" },
  155. },
  156. ],
  157. })
  158. const session = SessionV2.Info.make({
  159. id: SessionV2.ID.make("ses_compatible_variant"),
  160. projectID: ProjectV2.ID.global,
  161. title: "test",
  162. model: { id: catalog.id, providerID: catalog.providerID, variant: ModelV2.VariantID.make("high") },
  163. cost: Money.USD.zero,
  164. tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
  165. time: { created: DateTime.makeUnsafe(0), updated: DateTime.makeUnsafe(0) },
  166. location: { directory: AbsolutePath.make("/project") },
  167. })
  168. const resolved = yield* SessionRunnerModel.resolve(session, catalog)
  169. expect(resolved.route.defaults.http?.body).toEqual({
  170. custom_extension: { enabled: true },
  171. store: false,
  172. reasoning_effort: "high",
  173. })
  174. }),
  175. )
  176. it.effect("rejects an explicit unavailable Session variant during model resolution", () =>
  177. Effect.gen(function* () {
  178. const catalog = model(ProviderV2.aisdk("@ai-sdk/openai"), {
  179. settings: { baseURL: "https://openai.example/v1" },
  180. })
  181. const session = SessionV2.Info.make({
  182. id: SessionV2.ID.make("ses_model_variant_unavailable"),
  183. projectID: ProjectV2.ID.global,
  184. title: "test",
  185. model: {
  186. id: catalog.id,
  187. providerID: catalog.providerID,
  188. variant: ModelV2.VariantID.make("unknown"),
  189. },
  190. cost: Money.USD.zero,
  191. tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
  192. time: { created: DateTime.makeUnsafe(0), updated: DateTime.makeUnsafe(0) },
  193. location: { directory: AbsolutePath.make("/project") },
  194. })
  195. const failure = yield* SessionRunnerModel.resolve(session, catalog).pipe(Effect.flip)
  196. expect(failure).toMatchObject({
  197. _tag: "SessionRunnerModel.VariantUnavailableError",
  198. providerID: "test-provider",
  199. modelID: "test-model",
  200. variant: "unknown",
  201. })
  202. expect(failure.message).toBe("Variant unavailable for test-provider/test-model: unknown")
  203. }),
  204. )
  205. it.effect("overlays selected Anthropic Session variant settings", () =>
  206. Effect.gen(function* () {
  207. const catalog = model(ProviderV2.aisdk("@ai-sdk/anthropic"), {
  208. settings: { baseURL: "https://anthropic.example/v1" },
  209. variants: [
  210. {
  211. id: ModelV2.VariantID.make("high"),
  212. settings: { thinking: { type: "enabled", budgetTokens: 12000 } },
  213. headers: {},
  214. body: {},
  215. },
  216. ],
  217. })
  218. const session = SessionV2.Info.make({
  219. id: SessionV2.ID.make("ses_anthropic_variant"),
  220. projectID: ProjectV2.ID.global,
  221. title: "test",
  222. model: { id: catalog.id, providerID: catalog.providerID, variant: ModelV2.VariantID.make("high") },
  223. cost: Money.USD.zero,
  224. tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
  225. time: { created: DateTime.makeUnsafe(0), updated: DateTime.makeUnsafe(0) },
  226. location: { directory: AbsolutePath.make("/project") },
  227. })
  228. const resolved = yield* SessionRunnerModel.resolve(session, catalog)
  229. expect(resolved.route.defaults.http?.body).toEqual({
  230. custom_extension: { enabled: true },
  231. })
  232. expect(resolved.route.defaults.providerOptions).toEqual({
  233. anthropic: { thinking: { type: "enabled", budgetTokens: 12000 } },
  234. })
  235. }),
  236. )
  237. it.effect("maps catalog Anthropic AI SDK models into native routes", () =>
  238. Effect.gen(function* () {
  239. const resolved = yield* SessionRunnerModel.fromCatalogModel(
  240. model(ProviderV2.aisdk("@ai-sdk/anthropic"), {
  241. settings: { baseURL: "https://anthropic.example/v1" },
  242. }),
  243. )
  244. expect(resolved.route).toMatchObject({
  245. id: "anthropic-messages",
  246. providerMetadataKey: "anthropic",
  247. endpoint: { baseURL: "https://anthropic.example/v1" },
  248. })
  249. }),
  250. )
  251. it.effect("uses resolved credentials for bearer auth", () =>
  252. Effect.gen(function* () {
  253. const resolved = yield* SessionRunnerModel.fromCatalogModel(
  254. model(ProviderV2.aisdk("@ai-sdk/openai"), {
  255. settings: { baseURL: "https://openai.example/v1" },
  256. headers: {},
  257. body: {},
  258. }),
  259. Credential.Key.make({ type: "key", key: "secret" }),
  260. )
  261. const request = LLM.request({ model: resolved, prompt: "Hello" })
  262. const headers = yield* resolved.route.auth.apply({
  263. request,
  264. method: "POST",
  265. url: "https://openai.example/v1/responses",
  266. body: "{}",
  267. headers: Headers.empty,
  268. })
  269. expect(headers.authorization).toBe("Bearer secret")
  270. }),
  271. )
  272. it.effect("prefers stored credentials over configured auth", () =>
  273. Effect.gen(function* () {
  274. const credential = Credential.Key.make({ type: "key", key: "stored-secret", metadata: { tenant: "work" } })
  275. const resolved = yield* SessionRunnerModel.fromCatalogModel(
  276. model(ProviderV2.aisdk("@ai-sdk/openai"), {
  277. settings: { apiKey: "configured-secret", baseURL: "https://openai.example/v1" },
  278. headers: {},
  279. body: {},
  280. }),
  281. credential,
  282. )
  283. const headers = yield* resolved.route.auth.apply({
  284. request: LLM.request({ model: resolved, prompt: "Hello" }),
  285. method: "POST",
  286. url: "https://openai.example/v1/responses",
  287. body: "{}",
  288. headers: Headers.empty,
  289. })
  290. expect(headers.authorization).toBe("Bearer stored-secret")
  291. expect(resolved.route.defaults.http?.body).toEqual({ tenant: "work" })
  292. }),
  293. )
  294. it.effect("does not project OAuth account metadata into the request body", () =>
  295. Effect.gen(function* () {
  296. const resolved = yield* SessionRunnerModel.fromCatalogModel(
  297. model(ProviderV2.aisdk("@ai-sdk/openai"), {
  298. settings: { baseURL: "https://openai.example/v1" },
  299. headers: {},
  300. body: {},
  301. }),
  302. Credential.OAuth.make({
  303. type: "oauth",
  304. methodID: Integration.MethodID.make("device"),
  305. access: "secret",
  306. refresh: "refresh",
  307. expires: Date.now() + 60_000,
  308. metadata: { server: "https://console.example", orgID: "org_123" },
  309. }),
  310. )
  311. expect(resolved.route.defaults.http?.body).toEqual({})
  312. }),
  313. )
  314. it.effect("routes ChatGPT OAuth credentials to the codex backend", () =>
  315. Effect.gen(function* () {
  316. const resolved = yield* SessionRunnerModel.fromCatalogModel(
  317. model(ProviderV2.aisdk("@ai-sdk/openai"), {
  318. settings: { baseURL: "https://openai.example/v1" },
  319. headers: {},
  320. body: {},
  321. }),
  322. Credential.OAuth.make({
  323. type: "oauth",
  324. methodID: Integration.MethodID.make("chatgpt-browser"),
  325. access: "chatgpt-token",
  326. refresh: "refresh",
  327. expires: Date.now() + 60_000,
  328. metadata: { accountID: "acct_123" },
  329. }),
  330. )
  331. const request = LLM.request({ model: resolved, prompt: "Hello" })
  332. const headers = yield* resolved.route.auth.apply({
  333. request,
  334. method: "POST",
  335. url: "https://chatgpt.com/backend-api/codex/responses",
  336. body: "{}",
  337. headers: Headers.empty,
  338. })
  339. expect(resolved.route).toMatchObject({
  340. id: "openai-responses",
  341. endpoint: { baseURL: "https://chatgpt.com/backend-api/codex" },
  342. })
  343. expect(headers.authorization).toBe("Bearer chatgpt-token")
  344. expect(headers["chatgpt-account-id"]).toBe("acct_123")
  345. }),
  346. )
  347. it.effect("routes native OpenAI provider packages with ChatGPT credentials to the codex backend", () =>
  348. Effect.gen(function* () {
  349. const resolved = yield* SessionRunnerModel.fromCatalogModel(
  350. model("@opencode-ai/llm/providers/openai", {
  351. settings: { baseURL: "https://openai.example/v1" },
  352. }),
  353. Credential.OAuth.make({
  354. type: "oauth",
  355. methodID: Integration.MethodID.make("chatgpt-browser"),
  356. access: "chatgpt-token",
  357. refresh: "refresh",
  358. expires: Date.now() + 60_000,
  359. metadata: { accountID: "acct_123" },
  360. }),
  361. )
  362. const headers = yield* resolved.route.auth.apply({
  363. request: LLM.request({ model: resolved, prompt: "Hello" }),
  364. method: "POST",
  365. url: "https://chatgpt.com/backend-api/codex/responses",
  366. body: "{}",
  367. headers: Headers.empty,
  368. })
  369. expect(resolved.route.endpoint.baseURL).toBe("https://chatgpt.com/backend-api/codex")
  370. expect(headers.authorization).toBe("Bearer chatgpt-token")
  371. expect(headers["chatgpt-account-id"]).toBe("acct_123")
  372. }),
  373. )
  374. it.effect("does not route native OpenAI-compatible packages to the codex backend", () =>
  375. Effect.gen(function* () {
  376. const resolved = yield* SessionRunnerModel.fromCatalogModel(
  377. model("@opencode-ai/llm/providers/openai-compatible", {
  378. settings: { baseURL: "https://compatible.example/v1" },
  379. }),
  380. Credential.OAuth.make({
  381. type: "oauth",
  382. methodID: Integration.MethodID.make("chatgpt-browser"),
  383. access: "chatgpt-token",
  384. refresh: "refresh",
  385. expires: Date.now() + 60_000,
  386. metadata: { accountID: "acct_123" },
  387. }),
  388. )
  389. expect(resolved.route.id).toBe("openai-compatible-chat")
  390. expect(resolved.route.endpoint.baseURL).toBe("https://compatible.example/v1")
  391. }),
  392. )
  393. it.effect("maps legacy OpenAI organization and project settings to headers", () =>
  394. Effect.gen(function* () {
  395. const resolved = yield* SessionRunnerModel.fromCatalogModel(
  396. model(ProviderV2.aisdk("@ai-sdk/openai"), {
  397. settings: { organization: "org_123", project: "proj_123" },
  398. }),
  399. )
  400. expect(resolved.route.defaults.headers).toMatchObject({
  401. "OpenAI-Organization": "org_123",
  402. "OpenAI-Project": "proj_123",
  403. })
  404. }),
  405. )
  406. it.effect("routes ChatGPT OAuth credentials without an account id to the codex backend", () =>
  407. Effect.gen(function* () {
  408. const resolved = yield* SessionRunnerModel.fromCatalogModel(
  409. model(ProviderV2.aisdk("@ai-sdk/openai"), {
  410. settings: { baseURL: "https://openai.example/v1" },
  411. headers: {},
  412. body: {},
  413. }),
  414. Credential.OAuth.make({
  415. type: "oauth",
  416. methodID: Integration.MethodID.make("chatgpt-headless"),
  417. access: "chatgpt-token",
  418. refresh: "refresh",
  419. expires: Date.now() + 60_000,
  420. }),
  421. )
  422. const request = LLM.request({ model: resolved, prompt: "Hello" })
  423. const headers = yield* resolved.route.auth.apply({
  424. request,
  425. method: "POST",
  426. url: "https://chatgpt.com/backend-api/codex/responses",
  427. body: "{}",
  428. headers: Headers.empty,
  429. })
  430. expect(resolved.route.endpoint.baseURL).toBe("https://chatgpt.com/backend-api/codex")
  431. expect(headers.authorization).toBe("Bearer chatgpt-token")
  432. expect(headers["chatgpt-account-id"]).toBeUndefined()
  433. }),
  434. )
  435. it.effect("keeps non-ChatGPT OAuth credentials on the configured endpoint", () =>
  436. Effect.gen(function* () {
  437. const resolved = yield* SessionRunnerModel.fromCatalogModel(
  438. model(ProviderV2.aisdk("@ai-sdk/openai"), {
  439. settings: { baseURL: "https://openai.example/v1" },
  440. headers: {},
  441. body: {},
  442. }),
  443. Credential.OAuth.make({
  444. type: "oauth",
  445. methodID: Integration.MethodID.make("device"),
  446. access: "oauth-token",
  447. refresh: "refresh",
  448. expires: Date.now() + 60_000,
  449. metadata: { accountID: "acct_123" },
  450. }),
  451. )
  452. const request = LLM.request({ model: resolved, prompt: "Hello" })
  453. const headers = yield* resolved.route.auth.apply({
  454. request,
  455. method: "POST",
  456. url: "https://openai.example/v1/responses",
  457. body: "{}",
  458. headers: Headers.empty,
  459. })
  460. expect(resolved.route.endpoint.baseURL).toBe("https://openai.example/v1")
  461. expect(headers.authorization).toBe("Bearer oauth-token")
  462. expect(headers["chatgpt-account-id"]).toBeUndefined()
  463. }),
  464. )
  465. it.effect("loads dynamic native provider packages through the injected package loader", () =>
  466. Effect.gen(function* () {
  467. const native = yield* SessionRunnerModel.fromCatalogModel(
  468. model(ProviderV2.aisdk("@ai-sdk/openai"), {
  469. settings: { baseURL: "https://openai.example/v1" },
  470. }),
  471. )
  472. const resolved = yield* SessionRunnerModel.fromCatalogModel(
  473. model("@opencode-ai/llm/providers/custom", {
  474. settings: { region: "test" },
  475. headers: { "x-package": "header" },
  476. body: { custom: true },
  477. }),
  478. undefined,
  479. {
  480. loadPackage: (specifier) => {
  481. expect(specifier).toBe("@opencode-ai/llm/providers/custom")
  482. return Effect.succeed({
  483. model: (modelID, settings) => {
  484. expect(modelID).toBe("api-test-model")
  485. expect(settings).toEqual({
  486. region: "test",
  487. headers: { "x-package": "header" },
  488. body: { custom: true },
  489. limits: { context: 100, output: 20 },
  490. })
  491. return Model.make({ id: modelID, provider: "package-provider", route: native.route })
  492. },
  493. })
  494. },
  495. },
  496. )
  497. expect(resolved).toMatchObject({ id: "api-test-model", provider: "test-provider" })
  498. }),
  499. )
  500. it.effect("loads arbitrary AISDK packages through the injected AISDK loader", () =>
  501. Effect.gen(function* () {
  502. const native = yield* SessionRunnerModel.fromCatalogModel(
  503. model(ProviderV2.aisdk("@ai-sdk/openai"), {
  504. settings: { baseURL: "https://openai.example/v1" },
  505. }),
  506. )
  507. const resolved = yield* SessionRunnerModel.fromCatalogModel(
  508. model(ProviderV2.aisdk("@ai-sdk/google"), {
  509. modelID: "gemini-api-model",
  510. settings: { project: "test" },
  511. headers: { "x-aisdk": "header" },
  512. body: { custom: true },
  513. }),
  514. Credential.Key.make({ type: "key", key: "fallback-secret" }),
  515. {
  516. loadAISDK: (runtime) =>
  517. Effect.sync(() => {
  518. expect(runtime).toMatchObject({
  519. id: "test-model",
  520. modelID: "gemini-api-model",
  521. providerID: "test-provider",
  522. package: ProviderV2.aisdk("@ai-sdk/google"),
  523. settings: { project: "test", apiKey: "fallback-secret" },
  524. headers: { "x-aisdk": "header" },
  525. body: { custom: true },
  526. })
  527. return Model.make({
  528. id: runtime.modelID ?? runtime.id,
  529. provider: runtime.providerID,
  530. route: native.route,
  531. })
  532. }),
  533. },
  534. )
  535. expect(resolved).toMatchObject({ id: "gemini-api-model", provider: "test-provider" })
  536. }),
  537. )
  538. it.effect("rejects AISDK packages without an available loader", () =>
  539. Effect.gen(function* () {
  540. const failure = yield* SessionRunnerModel.fromCatalogModel(
  541. model(ProviderV2.aisdk("@ai-sdk/google"), {
  542. settings: { baseURL: "https://google.example/v1" },
  543. }),
  544. ).pipe(Effect.flip)
  545. expect(failure).toMatchObject({
  546. _tag: "SessionRunnerModel.UnsupportedPackageError",
  547. providerID: "test-provider",
  548. modelID: "test-model",
  549. package: "aisdk:@ai-sdk/google",
  550. })
  551. expect(failure.message).toBe("Unsupported package for test-provider/test-model: aisdk:@ai-sdk/google")
  552. }),
  553. )
  554. it.effect("reports whether a catalog model declares a provider package", () =>
  555. Effect.sync(() => {
  556. expect(SessionRunnerModel.supported(model(ProviderV2.aisdk("@ai-sdk/openai")))).toBe(true)
  557. expect(SessionRunnerModel.supported(model("@opencode-ai/llm/providers/custom"))).toBe(true)
  558. expect(SessionRunnerModel.supported(model(undefined))).toBe(false)
  559. }),
  560. )
  561. })