model-resolver.test.ts 27 KB

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