model-resolver.test.ts 31 KB

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