model-resolver.test.ts 23 KB

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