model-resolver.test.ts 24 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662
  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("applies plugin-projected OpenAI endpoint and headers", () =>
  284. Effect.gen(function* () {
  285. const resolved = yield* ModelResolver.fromCatalogModel(
  286. model("@opencode-ai/ai/providers/openai", {
  287. settings: { baseURL: "https://chatgpt.com/backend-api/codex" },
  288. headers: { "chatgpt-account-id": "acct_123" },
  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(resolved.route.defaults.headers).toMatchObject({ "chatgpt-account-id": "acct_123" })
  313. expect(headers.authorization).toBe("Bearer chatgpt-token")
  314. }),
  315. )
  316. it.effect("does not route native OpenAI-compatible packages to the codex backend", () =>
  317. Effect.gen(function* () {
  318. const resolved = yield* ModelResolver.fromCatalogModel(
  319. model("@opencode-ai/ai/providers/openai-compatible", {
  320. settings: { baseURL: "https://compatible.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. expect(resolved.route.id).toBe("openai-compatible-chat")
  332. expect(resolved.route.endpoint.baseURL).toBe("https://compatible.example/v1")
  333. }),
  334. )
  335. it.effect("maps legacy OpenAI organization and project settings to headers", () =>
  336. Effect.gen(function* () {
  337. const resolved = yield* ModelResolver.fromCatalogModel(
  338. model(Provider.aisdk("@ai-sdk/openai"), {
  339. settings: { organization: "org_123", project: "proj_123" },
  340. }),
  341. )
  342. expect(resolved.route.defaults.headers).toMatchObject({
  343. "OpenAI-Organization": "org_123",
  344. "OpenAI-Project": "proj_123",
  345. })
  346. }),
  347. )
  348. it.effect("keeps non-ChatGPT OAuth credentials on the configured endpoint", () =>
  349. Effect.gen(function* () {
  350. const resolved = yield* ModelResolver.fromCatalogModel(
  351. model(Provider.aisdk("@ai-sdk/openai"), {
  352. settings: { baseURL: "https://openai.example/v1" },
  353. headers: {},
  354. body: {},
  355. }),
  356. Credential.OAuth.make({
  357. type: "oauth",
  358. methodID: Integration.MethodID.make("device"),
  359. access: "oauth-token",
  360. refresh: "refresh",
  361. expires: Date.now() + 60_000,
  362. metadata: { accountID: "acct_123" },
  363. }),
  364. )
  365. const request = LLM.request({ model: resolved, prompt: "Hello" })
  366. const headers = yield* resolved.route.auth.apply({
  367. request,
  368. method: "POST",
  369. url: "https://openai.example/v1/responses",
  370. body: "{}",
  371. headers: Headers.empty,
  372. })
  373. expect(resolved.route.endpoint.baseURL).toBe("https://openai.example/v1")
  374. expect(headers.authorization).toBe("Bearer oauth-token")
  375. expect(headers["chatgpt-account-id"]).toBeUndefined()
  376. }),
  377. )
  378. it.effect("loads dynamic native provider packages through the injected package loader", () =>
  379. Effect.gen(function* () {
  380. const native = yield* ModelResolver.fromCatalogModel(
  381. model(Provider.aisdk("@ai-sdk/openai"), {
  382. settings: { baseURL: "https://openai.example/v1" },
  383. }),
  384. )
  385. const resolved = yield* ModelResolver.fromCatalogModel(
  386. model("@opencode-ai/ai/providers/custom", {
  387. settings: { region: "test" },
  388. headers: { "x-package": "header" },
  389. body: { custom: true },
  390. }),
  391. undefined,
  392. {
  393. loadPackage: (specifier) => {
  394. expect(specifier).toBe("@opencode-ai/ai/providers/custom")
  395. return Effect.succeed({
  396. model: (modelID, settings) => {
  397. expect(modelID).toBe("api-test-model")
  398. expect(settings).toEqual({
  399. region: "test",
  400. headers: { "x-package": "header" },
  401. body: { custom: true },
  402. limits: { context: 100, output: 20 },
  403. })
  404. return Model.make({ id: modelID, provider: "package-provider", route: native.route })
  405. },
  406. })
  407. },
  408. },
  409. )
  410. expect(resolved).toMatchObject({ id: "api-test-model", provider: "test-provider" })
  411. }),
  412. )
  413. it.effect("maps OAuth credentials to native provider auth settings", () =>
  414. Effect.gen(function* () {
  415. const native = yield* ModelResolver.fromCatalogModel(
  416. model(Provider.aisdk("@ai-sdk/openai"), {
  417. settings: { baseURL: "https://openai.example/v1" },
  418. }),
  419. )
  420. const credential = Credential.OAuth.make({
  421. type: "oauth",
  422. methodID: Integration.MethodID.make("device"),
  423. access: "oauth-token",
  424. refresh: "refresh",
  425. expires: Date.now() + 60_000,
  426. })
  427. const packages = [
  428. ["@opencode-ai/ai/providers/google-vertex", "accessToken"],
  429. ["@opencode-ai/ai/providers/google-vertex/gemini", "accessToken"],
  430. ["@opencode-ai/ai/providers/google-vertex/chat", "accessToken"],
  431. ["@opencode-ai/ai/providers/google-vertex/responses", "accessToken"],
  432. ["@opencode-ai/ai/providers/google-vertex/messages", "accessToken"],
  433. ["@opencode-ai/ai/providers/anthropic", "authToken"],
  434. ["@opencode-ai/ai/providers/anthropic-compatible", "authToken"],
  435. ] as const
  436. yield* Effect.forEach(packages, ([specifier, key]) =>
  437. ModelResolver.fromCatalogModel(model(specifier, { settings: { apiKey: "configured-key" } }), credential, {
  438. loadPackage: () =>
  439. Effect.succeed({
  440. model: (modelID, settings) => {
  441. expect(settings).toMatchObject({ [key]: "oauth-token" })
  442. expect(settings).not.toHaveProperty("apiKey")
  443. return Model.make({ id: modelID, provider: "package-provider", route: native.route })
  444. },
  445. }),
  446. }),
  447. )
  448. }),
  449. )
  450. it.effect("routes supported AISDK catalog packages through native provider packages", () =>
  451. Effect.gen(function* () {
  452. const native = yield* ModelResolver.fromCatalogModel(model(Provider.aisdk("@ai-sdk/openai")))
  453. const packages = [
  454. [
  455. "@ai-sdk/google",
  456. "@opencode-ai/ai/providers/google",
  457. { thinkingConfig: { thinkingLevel: "high" } },
  458. { gemini: { thinkingConfig: { thinkingLevel: "high" } } },
  459. ],
  460. [
  461. "@openrouter/ai-sdk-provider",
  462. "@opencode-ai/ai/providers/openrouter",
  463. { reasoning: { effort: "high" } },
  464. { openrouter: { reasoning: { effort: "high" } } },
  465. ],
  466. [
  467. "@ai-sdk/xai",
  468. "@opencode-ai/ai/providers/xai",
  469. { reasoningEffort: "high" },
  470. { xai: { reasoningEffort: "high" } },
  471. ],
  472. ] as const
  473. yield* Effect.forEach(packages, ([catalogPackage, nativePackage, sourceOptions, providerOptions]) =>
  474. ModelResolver.fromCatalogModel(
  475. model(Provider.aisdk(catalogPackage), {
  476. modelID: "api-model",
  477. settings: { baseURL: "https://provider.example/v1", ...sourceOptions },
  478. headers: { "x-provider": "header" },
  479. body: { custom: true },
  480. }),
  481. Credential.Key.make({ type: "key", key: "secret" }),
  482. {
  483. loadPackage: (specifier) => {
  484. expect(specifier).toBe(nativePackage)
  485. return Effect.succeed({
  486. model: (modelID, settings) => {
  487. expect(modelID).toBe("api-model")
  488. expect(settings).toMatchObject({
  489. apiKey: "secret",
  490. baseURL: "https://provider.example/v1",
  491. headers: { "x-provider": "header" },
  492. body: { custom: true },
  493. limits: { context: 100, output: 20 },
  494. providerOptions,
  495. })
  496. return Model.make({ id: modelID, provider: "native-provider", route: native.route })
  497. },
  498. })
  499. },
  500. loadAISDK: () => Effect.die("AI SDK loader should not be called"),
  501. },
  502. ),
  503. )
  504. }),
  505. )
  506. it.effect("loads supported AISDK catalog packages as native routes", () =>
  507. Effect.gen(function* () {
  508. const google = yield* ModelResolver.fromCatalogModel(
  509. model(Provider.aisdk("@ai-sdk/google"), { settings: { thinkingConfig: { thinkingBudget: 1_024 } } }),
  510. )
  511. const openrouter = yield* ModelResolver.fromCatalogModel(
  512. model(Provider.aisdk("@openrouter/ai-sdk-provider"), {
  513. settings: { reasoning: { effort: "high" } },
  514. }),
  515. )
  516. const xai = yield* ModelResolver.fromCatalogModel(
  517. model(Provider.aisdk("@ai-sdk/xai"), { settings: { reasoningEffort: "high" } }),
  518. )
  519. expect(google.route.id).toBe("gemini")
  520. expect(google.route.defaults.providerOptions).toEqual({
  521. gemini: { thinkingConfig: { thinkingBudget: 1_024 } },
  522. })
  523. expect(openrouter.route.id).toBe("openrouter")
  524. expect(openrouter.route.defaults.providerOptions).toEqual({ openrouter: { reasoning: { effort: "high" } } })
  525. expect(xai.route.id).toBe("openai-responses")
  526. expect(xai.route.defaults.providerOptions).toEqual({
  527. xai: { reasoningEffort: "high", store: false },
  528. })
  529. }),
  530. )
  531. it.effect("loads arbitrary AISDK packages through the injected AISDK loader", () =>
  532. Effect.gen(function* () {
  533. const native = yield* ModelResolver.fromCatalogModel(
  534. model(Provider.aisdk("@ai-sdk/openai"), {
  535. settings: { baseURL: "https://openai.example/v1" },
  536. }),
  537. )
  538. const resolved = yield* ModelResolver.fromCatalogModel(
  539. model(Provider.aisdk("@ai-sdk/mistral"), {
  540. modelID: "mistral-api-model",
  541. settings: { project: "test" },
  542. headers: { "x-aisdk": "header" },
  543. body: { custom: true },
  544. }),
  545. Credential.Key.make({ type: "key", key: "fallback-secret" }),
  546. {
  547. loadAISDK: (runtime) =>
  548. Effect.sync(() => {
  549. expect(runtime).toMatchObject({
  550. id: "test-model",
  551. modelID: "mistral-api-model",
  552. providerID: "test-provider",
  553. package: Provider.aisdk("@ai-sdk/mistral"),
  554. settings: { project: "test", apiKey: "fallback-secret" },
  555. headers: { "x-aisdk": "header" },
  556. body: { custom: true },
  557. })
  558. return Model.make({
  559. id: runtime.modelID ?? runtime.id,
  560. provider: runtime.providerID,
  561. route: native.route,
  562. })
  563. }),
  564. },
  565. )
  566. expect(resolved).toMatchObject({ id: "mistral-api-model", provider: "test-provider" })
  567. }),
  568. )
  569. it.effect("rejects AISDK packages without an available loader", () =>
  570. Effect.gen(function* () {
  571. const failure = yield* ModelResolver.fromCatalogModel(
  572. model(Provider.aisdk("@ai-sdk/mistral"), {
  573. settings: { baseURL: "https://mistral.example/v1" },
  574. }),
  575. ).pipe(Effect.flip)
  576. expect(failure).toMatchObject({
  577. _tag: "SessionRunnerModel.UnsupportedPackageError",
  578. providerID: "test-provider",
  579. modelID: "test-model",
  580. package: "aisdk:@ai-sdk/mistral",
  581. })
  582. expect(failure.message).toBe("Unsupported package for test-provider/test-model: aisdk:@ai-sdk/mistral")
  583. }),
  584. )
  585. it.effect("drops an empty API key before loading an AISDK package", () =>
  586. Effect.gen(function* () {
  587. const native = yield* ModelResolver.fromCatalogModel(
  588. model(Provider.aisdk("@ai-sdk/openai"), {
  589. settings: { baseURL: "https://openai.example/v1" },
  590. }),
  591. )
  592. yield* ModelResolver.fromCatalogModel(
  593. model(Provider.aisdk("@ai-sdk/mistral"), {
  594. settings: { apiKey: "", baseURL: "https://mistral.example/v1" },
  595. }),
  596. undefined,
  597. {
  598. loadAISDK: (runtime) =>
  599. Effect.sync(() => {
  600. expect(runtime.settings).not.toHaveProperty("apiKey")
  601. return native
  602. }),
  603. },
  604. )
  605. }),
  606. )
  607. it.effect("reports whether a catalog model declares a provider package", () =>
  608. Effect.sync(() => {
  609. expect(ModelResolver.supported(model(Provider.aisdk("@ai-sdk/openai")))).toBe(true)
  610. expect(ModelResolver.supported(model("@opencode-ai/ai/providers/custom"))).toBe(true)
  611. expect(ModelResolver.supported(model(undefined))).toBe(false)
  612. }),
  613. )
  614. })