model-resolver.test.ts 30 KB

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