model-resolver.test.ts 42 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146
  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, Layer } 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 { Catalog } from "@opencode-ai/core/catalog"
  13. import { AISDK } from "@opencode-ai/core/aisdk"
  14. import { Npm } from "@opencode-ai/util/npm"
  15. import { it } from "./lib/effect"
  16. interface ModelOptions {
  17. readonly providerID?: Provider.ID
  18. readonly modelID?: string
  19. readonly compatibility?: Compatibility
  20. readonly settings?: Info["settings"]
  21. readonly headers?: Info["headers"]
  22. readonly body?: Info["body"]
  23. readonly variants?: Info["variants"]
  24. readonly limit?: Info["limit"]
  25. }
  26. const model = (packageName: string | undefined, options: ModelOptions = {}) =>
  27. Info.make({
  28. id: ID.make("test-model"),
  29. modelID: ID.make(options.modelID ?? "api-test-model"),
  30. providerID: options.providerID ?? Provider.ID.make("test-provider"),
  31. name: "Test model",
  32. compatibility: options.compatibility,
  33. package: packageName,
  34. settings: options.settings ?? {},
  35. headers: options.headers ?? { "x-test": "header" },
  36. body: options.body ?? { custom_extension: { enabled: true } },
  37. capabilities: { tools: true, input: ["text"], output: ["text"] },
  38. variants: options.variants ?? [],
  39. time: { released: 0 },
  40. cost: [],
  41. status: "active",
  42. enabled: true,
  43. limit: options.limit ?? { context: 100, output: 20 },
  44. })
  45. function withEnv<A, E, R>(variables: Record<string, string | undefined>, effect: () => Effect.Effect<A, E, R>) {
  46. return Effect.acquireUseRelease(
  47. Effect.sync(() => {
  48. const previous = Object.fromEntries(Object.keys(variables).map((key) => [key, process.env[key]]))
  49. Object.entries(variables).forEach(([key, value]) => {
  50. if (value === undefined) delete process.env[key]
  51. else process.env[key] = value
  52. })
  53. return previous
  54. }),
  55. effect,
  56. (previous) =>
  57. Effect.sync(() => {
  58. Object.entries(previous).forEach(([key, value]) => {
  59. if (value === undefined) delete process.env[key]
  60. else process.env[key] = value
  61. })
  62. }),
  63. )
  64. }
  65. describe("ModelResolver", () => {
  66. it.effect("constructs native Azure requests with deployment IDs and projected resource URLs", () =>
  67. Effect.gen(function* () {
  68. const responses = yield* ModelResolver.fromCatalogModel(
  69. model(Provider.aisdk("@ai-sdk/azure"), {
  70. providerID: Provider.ID.azure,
  71. modelID: "responses-deployment",
  72. settings: { resourceName: "modern-resource", apiVersion: "2025-01-01-preview" },
  73. }),
  74. Credential.Key.make({ type: "key", key: "secret" }),
  75. )
  76. const chat = yield* ModelResolver.fromCatalogModel(
  77. model(Provider.aisdk("@ai-sdk/azure"), {
  78. providerID: Provider.ID.azure,
  79. modelID: "chat-deployment",
  80. settings: { resourceName: "modern-resource", useCompletionUrls: true },
  81. }),
  82. )
  83. const deployment = yield* ModelResolver.fromCatalogModel(
  84. model(Provider.aisdk("@ai-sdk/azure"), {
  85. providerID: Provider.ID.azure,
  86. modelID: "legacy-url-deployment",
  87. settings: {
  88. resourceName: "modern-resource",
  89. apiVersion: "2025-01-01-preview",
  90. useDeploymentBasedUrls: true,
  91. },
  92. }),
  93. )
  94. const compatible = yield* ModelResolver.fromCatalogModel(
  95. model(Provider.aisdk("@ai-sdk/openai-compatible"), {
  96. providerID: Provider.ID.azure,
  97. modelID: "legacy-deployment",
  98. settings: {
  99. resourceName: "legacy-resource",
  100. baseURL: "https://legacy-resource.cognitiveservices.azure.com/openai",
  101. },
  102. }),
  103. )
  104. expect(responses).toMatchObject({ id: "responses-deployment", provider: "azure" })
  105. expect(responses.route).toMatchObject({
  106. id: "azure-openai-responses",
  107. endpoint: {
  108. baseURL: "https://modern-resource.openai.azure.com/openai/v1",
  109. query: { "api-version": "2025-01-01-preview" },
  110. },
  111. })
  112. expect(chat).toMatchObject({ id: "chat-deployment", provider: "azure" })
  113. expect(chat.route.id).toBe("azure-openai-chat")
  114. expect(deployment).toMatchObject({ id: "legacy-url-deployment", provider: "azure" })
  115. expect(deployment.route.endpoint).toMatchObject({
  116. baseURL: "https://modern-resource.openai.azure.com/openai/deployments/legacy-url-deployment",
  117. query: { "api-version": "2025-01-01-preview" },
  118. })
  119. expect(compatible).toMatchObject({ id: "legacy-deployment", provider: "azure" })
  120. expect(compatible.route.endpoint.baseURL).toBe("https://legacy-resource.cognitiveservices.azure.com/openai")
  121. }),
  122. )
  123. it.effect("resolves environment templates before native providers inspect endpoints", () =>
  124. withEnv({ AZURE_HOST: "resource.openai.azure.com" }, () =>
  125. Effect.gen(function* () {
  126. const resolved = yield* ModelResolver.fromCatalogModel(
  127. model(Provider.aisdk("@ai-sdk/azure"), {
  128. providerID: Provider.ID.azure,
  129. settings: { baseURL: "https://${AZURE_HOST}/openai" },
  130. }),
  131. )
  132. expect(resolved.route.endpoint).toMatchObject({
  133. baseURL: "https://resource.openai.azure.com/openai/v1",
  134. query: { "api-version": "v1" },
  135. })
  136. }),
  137. ),
  138. )
  139. it.effect("maps Bedrock Mantle models to native Responses and safeguards to Chat", () =>
  140. Effect.gen(function* () {
  141. const credential = Credential.Key.make({ type: "key", key: "secret" })
  142. const responses = yield* ModelResolver.fromCatalogModel(
  143. model(Provider.aisdk("@ai-sdk/amazon-bedrock/mantle"), {
  144. modelID: "openai.gpt-oss-120b",
  145. settings: { region: "us-east-2" },
  146. }),
  147. credential,
  148. )
  149. const chat = yield* ModelResolver.fromCatalogModel(
  150. model(Provider.aisdk("@ai-sdk/amazon-bedrock/mantle"), {
  151. modelID: "openai.gpt-oss-safeguard-20b",
  152. settings: { region: "us-east-2" },
  153. }),
  154. credential,
  155. )
  156. expect(responses.route).toMatchObject({
  157. id: "bedrock-mantle-responses",
  158. endpoint: { baseURL: "https://bedrock-mantle.us-east-2.api.aws/v1" },
  159. })
  160. expect(chat.route).toMatchObject({
  161. id: "bedrock-mantle-chat",
  162. endpoint: { baseURL: "https://bedrock-mantle.us-east-2.api.aws/v1" },
  163. })
  164. }),
  165. )
  166. it.effect("resolves Bedrock Mantle catalog endpoints from the configured region", () =>
  167. withEnv({ AWS_REGION: undefined }, () =>
  168. Effect.gen(function* () {
  169. const catalog = model(Provider.aisdk("@ai-sdk/amazon-bedrock/mantle"), {
  170. providerID: Provider.ID.amazonBedrock,
  171. modelID: "openai.gpt-5.5",
  172. settings: {
  173. region: "us-west-2",
  174. baseURL: "https://bedrock-mantle.${AWS_REGION}.api.aws/openai/v1",
  175. },
  176. })
  177. const resolved = yield* ModelResolver.fromCatalogModel(catalog)
  178. expect(resolved.route).toMatchObject({
  179. id: "bedrock-mantle-responses",
  180. endpoint: { baseURL: "https://bedrock-mantle.us-west-2.api.aws/openai/v1" },
  181. })
  182. expect(catalog.settings?.baseURL).toBe("https://bedrock-mantle.${AWS_REGION}.api.aws/openai/v1")
  183. }),
  184. ),
  185. )
  186. it.effect("prefers the configured Mantle region over the environment", () =>
  187. withEnv({ AWS_REGION: "us-east-1" }, () =>
  188. Effect.gen(function* () {
  189. const resolved = yield* ModelResolver.fromCatalogModel(
  190. model(Provider.aisdk("@ai-sdk/amazon-bedrock/mantle"), {
  191. modelID: "openai.gpt-5.5",
  192. settings: {
  193. region: "us-west-2",
  194. baseURL: "https://bedrock-mantle.${AWS_REGION}.api.aws/openai/v1",
  195. },
  196. }),
  197. )
  198. expect(resolved.route.endpoint.baseURL).toBe("https://bedrock-mantle.us-west-2.api.aws/openai/v1")
  199. }),
  200. ),
  201. )
  202. it.effect("uses the API modelID instead of the catalog ID for native OpenAI routes", () =>
  203. Effect.gen(function* () {
  204. const catalog = model(Provider.aisdk("@ai-sdk/openai"), {
  205. settings: { baseURL: "https://openai.example/v1" },
  206. limit: { context: 100, input: 80, output: 20 },
  207. })
  208. const resolved = yield* ModelResolver.fromCatalogModel(catalog)
  209. expect(catalog.id).toBe(ID.make("test-model"))
  210. expect(resolved).toMatchObject({ id: "api-test-model", provider: "test-provider" })
  211. expect(resolved.route).toMatchObject({
  212. id: "openai-responses",
  213. providerMetadataKey: "openai",
  214. endpoint: { baseURL: "https://openai.example/v1" },
  215. defaults: {
  216. headers: { "x-test": "header" },
  217. limits: { context: 100, input: 80, output: 20 },
  218. http: { body: { custom_extension: { enabled: true } } },
  219. },
  220. })
  221. const prepared = yield* compileRequest(LLM.request({ model: resolved, prompt: "Hello" }))
  222. expect(prepared.body.max_output_tokens).toBeUndefined()
  223. expect(JSON.stringify(prepared.body)).not.toContain("max_output_tokens")
  224. }),
  225. )
  226. it.effect("keeps catalog apiKey credentials out of provider JSON", () =>
  227. Effect.gen(function* () {
  228. const resolved = yield* ModelResolver.fromCatalogModel(
  229. model(Provider.aisdk("@ai-sdk/openai"), {
  230. settings: { apiKey: "secret", baseURL: "https://openai.example/v1" },
  231. }),
  232. )
  233. const prepared = yield* compileRequest(LLM.request({ model: resolved, prompt: "Hello" }))
  234. expect(JSON.stringify(prepared.body)).not.toContain("apiKey")
  235. expect(JSON.stringify(prepared.body)).not.toContain("secret")
  236. }),
  237. )
  238. it.effect("treats an empty configured API key as omitted", () =>
  239. Effect.gen(function* () {
  240. const resolved = yield* ModelResolver.fromCatalogModel(
  241. model(Provider.aisdk("@ai-sdk/openai"), {
  242. settings: { apiKey: "", baseURL: "https://openai.example/v1" },
  243. }),
  244. )
  245. const headers = yield* resolved.route.auth.apply({
  246. request: LLM.request({ model: resolved, prompt: "Hello" }),
  247. method: "POST",
  248. url: "https://openai.example/v1/responses",
  249. body: "{}",
  250. headers: Headers.empty,
  251. })
  252. expect(headers.authorization).toBeUndefined()
  253. }),
  254. )
  255. it.effect("uses no native API-key auth for an explicitly enabled provider without credentials", () => {
  256. const selected = model(Provider.aisdk("@ai-sdk/google"), {
  257. providerID: Provider.ID.make("gateway"),
  258. settings: { baseURL: "https://gateway.example.com/v1" },
  259. headers: { "cf-access-token": "access-token" },
  260. })
  261. const provider = Provider.Info.make({
  262. ...Provider.Info.empty(selected.providerID),
  263. activation: "enabled",
  264. package: selected.package ?? "",
  265. settings: selected.settings,
  266. headers: selected.headers,
  267. })
  268. const catalog = Layer.mock(Catalog.Service, {
  269. provider: {
  270. get: () => Effect.succeed(provider),
  271. all: () => Effect.die("unused"),
  272. available: () => Effect.die("unused"),
  273. },
  274. model: {
  275. get: () => Effect.succeed(selected),
  276. all: () => Effect.die("unused"),
  277. available: () => Effect.die("unused"),
  278. default: () => Effect.die("unused"),
  279. small: () => Effect.die("unused"),
  280. },
  281. })
  282. const integrations = Layer.mock(Integration.Service, {
  283. connection: {
  284. active: () => Effect.succeed(undefined),
  285. resolve: () => Effect.die("unused"),
  286. key: () => Effect.die("unused"),
  287. update: () => Effect.die("unused"),
  288. remove: () => Effect.die("unused"),
  289. },
  290. oauth: {
  291. connect: () => Effect.die("unused"),
  292. status: () => Effect.die("unused"),
  293. complete: () => Effect.die("unused"),
  294. cancel: () => Effect.die("unused"),
  295. },
  296. command: {
  297. connect: () => Effect.die("unused"),
  298. status: () => Effect.die("unused"),
  299. cancel: () => Effect.die("unused"),
  300. },
  301. })
  302. const npm = Layer.mock(Npm.Service, {
  303. add: () => Effect.die("unused"),
  304. which: () => Effect.die("unused"),
  305. })
  306. const aisdk = Layer.mock(AISDK.Service, {
  307. hook: {
  308. sdk: () => Effect.die("unused"),
  309. language: () => Effect.die("unused"),
  310. },
  311. model: () => Effect.die("unused"),
  312. })
  313. const layer = ModelResolver.layer.pipe(Layer.provide(Layer.mergeAll(catalog, integrations, npm, aisdk)))
  314. return withEnv({ GOOGLE_GENERATIVE_AI_API_KEY: undefined }, () =>
  315. Effect.gen(function* () {
  316. const resolver = yield* ModelResolver.Service
  317. const resolved = yield* resolver.resolveModel(selected)
  318. const headers = yield* resolved.model.route.auth.apply({
  319. request: LLM.request({ model: resolved.model, prompt: "Hello" }),
  320. method: "POST",
  321. url: "https://gateway.example.com/v1",
  322. body: "{}",
  323. headers: Headers.fromInput(resolved.model.route.defaults.headers),
  324. })
  325. expect(headers["cf-access-token"]).toBe("access-token")
  326. expect(headers.authorization).toBeUndefined()
  327. expect(headers["x-goog-api-key"]).toBeUndefined()
  328. }).pipe(Effect.provide(layer)),
  329. )
  330. })
  331. it.effect("keeps native provider environment auth strict when no API key is configured", () =>
  332. withEnv({ GOOGLE_GENERATIVE_AI_API_KEY: undefined }, () =>
  333. Effect.gen(function* () {
  334. const resolved = yield* ModelResolver.fromCatalogModel(
  335. model(Provider.aisdk("@ai-sdk/google"), {
  336. settings: { baseURL: "https://google.example.com/v1" },
  337. }),
  338. )
  339. const exit = yield* Effect.exit(
  340. resolved.route.auth.apply({
  341. request: LLM.request({ model: resolved, prompt: "Hello" }),
  342. method: "POST",
  343. url: "https://google.example.com/v1",
  344. body: "{}",
  345. headers: Headers.empty,
  346. }),
  347. )
  348. expect(exit._tag).toBe("Failure")
  349. }),
  350. ),
  351. )
  352. it.effect("uses merged API settings for OpenAI-compatible auth and request defaults", () =>
  353. Effect.gen(function* () {
  354. const resolved = yield* ModelResolver.fromCatalogModel(
  355. model(Provider.aisdk("@ai-sdk/openai-compatible"), {
  356. compatibility: {
  357. reasoningField: "vendor_reasoning",
  358. maxTokensField: "max_completion_tokens",
  359. requireFinishReason: false,
  360. },
  361. settings: {
  362. apiKey: "settings-secret",
  363. baseURL: "https://compatible.example/v1",
  364. compatibility: "strict",
  365. },
  366. headers: {},
  367. body: {},
  368. }),
  369. )
  370. const request = LLM.request({ model: resolved, prompt: "Hello", generation: { maxTokens: 10 } })
  371. const prepared = yield* compileRequest(request)
  372. const headers = yield* resolved.route.auth.apply({
  373. request,
  374. method: "POST",
  375. url: "https://compatible.example/v1/chat/completions",
  376. body: "{}",
  377. headers: Headers.empty,
  378. })
  379. expect(headers.authorization).toBe("Bearer settings-secret")
  380. expect(resolved.route.id).toBe("openai-compatible-chat")
  381. expect(resolved.compatibility?.reasoningField).toBe("vendor_reasoning")
  382. expect(resolved.compatibility?.maxTokensField).toBe("max_completion_tokens")
  383. expect(resolved.compatibility?.requireFinishReason).toBe(false)
  384. expect(prepared.body).toMatchObject({ max_completion_tokens: 10 })
  385. expect(prepared.body).not.toHaveProperty("max_tokens")
  386. expect(resolved.route.endpoint.baseURL).toBe("https://compatible.example/v1")
  387. expect(resolved.route.defaults.http?.body).toEqual({})
  388. }),
  389. )
  390. it.effect("resolves provider URLs from environment without mutating the catalog model", () =>
  391. withEnv({ ACME_HOST: "api.acme.test" }, () =>
  392. Effect.gen(function* () {
  393. const catalog = model(Provider.aisdk("@ai-sdk/openai-compatible"), {
  394. settings: { baseURL: "https://${ACME_HOST}/v1" },
  395. })
  396. const resolved = yield* ModelResolver.fromCatalogModel(catalog)
  397. expect(resolved.route.endpoint.baseURL).toBe("https://api.acme.test/v1")
  398. expect(catalog.settings?.baseURL).toBe("https://${ACME_HOST}/v1")
  399. }),
  400. ),
  401. )
  402. it.effect("rejects unresolved variables in constructed provider routes", () =>
  403. withEnv({ REQUIRED_HOST: undefined }, () =>
  404. Effect.gen(function* () {
  405. const failure = yield* ModelResolver.fromCatalogModel(
  406. model(Provider.aisdk("@ai-sdk/openai-compatible"), {
  407. settings: { baseURL: "https://${REQUIRED_HOST}/${REQUIRED_PATH}/v1" },
  408. }),
  409. ).pipe(Effect.flip)
  410. expect(failure).toMatchObject({
  411. _tag: "SessionRunnerModel.UnresolvedProviderVariablesError",
  412. providerID: "test-provider",
  413. modelID: "test-model",
  414. variables: ["REQUIRED_HOST", "REQUIRED_PATH"],
  415. })
  416. expect(failure.message).toBe(
  417. "Cannot initialize test-provider/test-model: REQUIRED_HOST, REQUIRED_PATH are required to resolve the provider endpoint",
  418. )
  419. }),
  420. ),
  421. )
  422. it.effect("overlays selected OpenAI variant settings and bodies", () =>
  423. Effect.gen(function* () {
  424. const catalog = model(Provider.aisdk("@ai-sdk/openai"), {
  425. settings: { baseURL: "https://openai.example/v1" },
  426. variants: [
  427. {
  428. id: VariantID.make("high"),
  429. settings: { reasoningEffort: "high" },
  430. headers: { "x-variant": "high" },
  431. body: {
  432. store: false,
  433. service_tier: "priority",
  434. temperature: 0.2,
  435. },
  436. },
  437. ],
  438. })
  439. const resolved = yield* ModelResolver.resolveModel(catalog, VariantID.make("high"))
  440. expect(resolved.route.defaults.headers).toMatchObject({ "x-test": "header", "x-variant": "high" })
  441. expect(resolved.route.defaults.http?.body).toEqual({
  442. custom_extension: { enabled: true },
  443. store: false,
  444. service_tier: "priority",
  445. temperature: 0.2,
  446. })
  447. expect(resolved.route.defaults.providerOptions).toEqual({
  448. openai: { store: false, reasoningEffort: "high" },
  449. })
  450. }),
  451. )
  452. it.effect("overlays selected OpenAI-compatible variant bodies", () =>
  453. Effect.gen(function* () {
  454. const catalog = model(Provider.aisdk("@ai-sdk/openai-compatible"), {
  455. settings: { baseURL: "https://compatible.example/v1" },
  456. variants: [
  457. {
  458. id: VariantID.make("high"),
  459. settings: {},
  460. headers: {},
  461. body: { store: false, reasoning_effort: "high" },
  462. },
  463. ],
  464. })
  465. const resolved = yield* ModelResolver.resolveModel(catalog, VariantID.make("high"))
  466. expect(resolved.route.defaults.http?.body).toEqual({
  467. custom_extension: { enabled: true },
  468. store: false,
  469. reasoning_effort: "high",
  470. })
  471. }),
  472. )
  473. it.effect("rejects an explicit unavailable variant during model resolution", () =>
  474. Effect.gen(function* () {
  475. const catalog = model(Provider.aisdk("@ai-sdk/openai"), {
  476. settings: { baseURL: "https://openai.example/v1" },
  477. })
  478. const failure = yield* ModelResolver.resolveModel(catalog, VariantID.make("unknown")).pipe(Effect.flip)
  479. expect(failure).toMatchObject({
  480. _tag: "SessionRunnerModel.VariantUnavailableError",
  481. providerID: "test-provider",
  482. modelID: "test-model",
  483. variant: "unknown",
  484. })
  485. expect(failure.message).toBe("Variant unavailable for test-provider/test-model: unknown")
  486. }),
  487. )
  488. it.effect("overlays selected Anthropic variant settings", () =>
  489. Effect.gen(function* () {
  490. const catalog = model(Provider.aisdk("@ai-sdk/anthropic"), {
  491. settings: { baseURL: "https://anthropic.example/v1" },
  492. variants: [
  493. {
  494. id: VariantID.make("high"),
  495. settings: { thinking: { type: "enabled", budgetTokens: 12000 } },
  496. headers: {},
  497. body: {},
  498. },
  499. ],
  500. })
  501. const resolved = yield* ModelResolver.resolveModel(catalog, VariantID.make("high"))
  502. expect(resolved.route.defaults.http?.body).toEqual({
  503. custom_extension: { enabled: true },
  504. })
  505. expect(resolved.route.defaults.providerOptions).toEqual({
  506. anthropic: { thinking: { type: "enabled", budgetTokens: 12000 } },
  507. })
  508. }),
  509. )
  510. it.effect("maps catalog Anthropic AI SDK models into native routes", () =>
  511. Effect.gen(function* () {
  512. const resolved = yield* ModelResolver.fromCatalogModel(
  513. model(Provider.aisdk("@ai-sdk/anthropic"), {
  514. settings: { baseURL: "https://anthropic.example/v1" },
  515. }),
  516. )
  517. expect(resolved.route).toMatchObject({
  518. id: "anthropic-messages",
  519. providerMetadataKey: "anthropic",
  520. endpoint: { baseURL: "https://anthropic.example/v1" },
  521. })
  522. }),
  523. )
  524. it.effect("uses resolved credentials for bearer auth", () =>
  525. Effect.gen(function* () {
  526. const resolved = yield* ModelResolver.fromCatalogModel(
  527. model(Provider.aisdk("@ai-sdk/openai"), {
  528. settings: { baseURL: "https://openai.example/v1" },
  529. headers: {},
  530. body: {},
  531. }),
  532. Credential.Key.make({ type: "key", key: "secret" }),
  533. )
  534. const request = LLM.request({ model: resolved, prompt: "Hello" })
  535. const headers = yield* resolved.route.auth.apply({
  536. request,
  537. method: "POST",
  538. url: "https://openai.example/v1/responses",
  539. body: "{}",
  540. headers: Headers.empty,
  541. })
  542. expect(headers.authorization).toBe("Bearer secret")
  543. }),
  544. )
  545. it.effect("prefers stored credentials over configured auth", () =>
  546. Effect.gen(function* () {
  547. const credential = Credential.Key.make({ type: "key", key: "stored-secret", metadata: { tenant: "work" } })
  548. const resolved = yield* ModelResolver.fromCatalogModel(
  549. model(Provider.aisdk("@ai-sdk/openai"), {
  550. settings: { apiKey: "configured-secret", baseURL: "https://openai.example/v1" },
  551. headers: {},
  552. body: {},
  553. }),
  554. credential,
  555. )
  556. const headers = yield* resolved.route.auth.apply({
  557. request: LLM.request({ model: resolved, prompt: "Hello" }),
  558. method: "POST",
  559. url: "https://openai.example/v1/responses",
  560. body: "{}",
  561. headers: Headers.empty,
  562. })
  563. expect(headers.authorization).toBe("Bearer stored-secret")
  564. expect(resolved.route.defaults.http?.body).toEqual({ tenant: "work" })
  565. }),
  566. )
  567. it.effect("does not project OAuth account metadata into the request body", () =>
  568. Effect.gen(function* () {
  569. const resolved = yield* ModelResolver.fromCatalogModel(
  570. model(Provider.aisdk("@ai-sdk/openai"), {
  571. settings: { baseURL: "https://openai.example/v1" },
  572. headers: {},
  573. body: {},
  574. }),
  575. Credential.OAuth.make({
  576. type: "oauth",
  577. methodID: Integration.MethodID.make("device"),
  578. access: "secret",
  579. refresh: "refresh",
  580. expires: Date.now() + 60_000,
  581. metadata: { server: "https://console.example", orgID: "org_123" },
  582. }),
  583. )
  584. expect(resolved.route.defaults.http?.body).toEqual({})
  585. }),
  586. )
  587. it.effect("applies plugin-projected OpenAI endpoint and headers", () =>
  588. Effect.gen(function* () {
  589. const resolved = yield* ModelResolver.fromCatalogModel(
  590. model("@opencode-ai/ai/providers/openai", {
  591. settings: { baseURL: "https://chatgpt.com/backend-api/codex" },
  592. headers: { "chatgpt-account-id": "acct_123" },
  593. body: {},
  594. }),
  595. Credential.OAuth.make({
  596. type: "oauth",
  597. methodID: Integration.MethodID.make("chatgpt-browser"),
  598. access: "chatgpt-token",
  599. refresh: "refresh",
  600. expires: Date.now() + 60_000,
  601. metadata: { accountID: "acct_123" },
  602. }),
  603. )
  604. const request = LLM.request({ model: resolved, prompt: "Hello" })
  605. const headers = yield* resolved.route.auth.apply({
  606. request,
  607. method: "POST",
  608. url: "https://chatgpt.com/backend-api/codex/responses",
  609. body: "{}",
  610. headers: Headers.empty,
  611. })
  612. expect(resolved.route).toMatchObject({
  613. id: "openai-responses",
  614. endpoint: { baseURL: "https://chatgpt.com/backend-api/codex" },
  615. })
  616. expect(resolved.route.defaults.headers).toMatchObject({ "chatgpt-account-id": "acct_123" })
  617. expect(headers.authorization).toBe("Bearer chatgpt-token")
  618. }),
  619. )
  620. it.effect("does not route native OpenAI-compatible packages to the codex backend", () =>
  621. Effect.gen(function* () {
  622. const resolved = yield* ModelResolver.fromCatalogModel(
  623. model("@opencode-ai/ai/providers/openai-compatible", {
  624. settings: { baseURL: "https://compatible.example/v1" },
  625. }),
  626. Credential.OAuth.make({
  627. type: "oauth",
  628. methodID: Integration.MethodID.make("chatgpt-browser"),
  629. access: "chatgpt-token",
  630. refresh: "refresh",
  631. expires: Date.now() + 60_000,
  632. metadata: { accountID: "acct_123" },
  633. }),
  634. )
  635. expect(resolved.route.id).toBe("openai-compatible-chat")
  636. expect(resolved.route.endpoint.baseURL).toBe("https://compatible.example/v1")
  637. }),
  638. )
  639. it.effect("maps legacy OpenAI organization and project settings to headers", () =>
  640. Effect.gen(function* () {
  641. const resolved = yield* ModelResolver.fromCatalogModel(
  642. model(Provider.aisdk("@ai-sdk/openai"), {
  643. settings: { organization: "org_123", project: "proj_123" },
  644. }),
  645. )
  646. expect(resolved.route.defaults.headers).toMatchObject({
  647. "OpenAI-Organization": "org_123",
  648. "OpenAI-Project": "proj_123",
  649. })
  650. }),
  651. )
  652. it.effect("keeps non-ChatGPT OAuth credentials on the configured endpoint", () =>
  653. Effect.gen(function* () {
  654. const resolved = yield* ModelResolver.fromCatalogModel(
  655. model(Provider.aisdk("@ai-sdk/openai"), {
  656. settings: { baseURL: "https://openai.example/v1" },
  657. headers: {},
  658. body: {},
  659. }),
  660. Credential.OAuth.make({
  661. type: "oauth",
  662. methodID: Integration.MethodID.make("device"),
  663. access: "oauth-token",
  664. refresh: "refresh",
  665. expires: Date.now() + 60_000,
  666. metadata: { accountID: "acct_123" },
  667. }),
  668. )
  669. const request = LLM.request({ model: resolved, prompt: "Hello" })
  670. const headers = yield* resolved.route.auth.apply({
  671. request,
  672. method: "POST",
  673. url: "https://openai.example/v1/responses",
  674. body: "{}",
  675. headers: Headers.empty,
  676. })
  677. expect(resolved.route.endpoint.baseURL).toBe("https://openai.example/v1")
  678. expect(headers.authorization).toBe("Bearer oauth-token")
  679. expect(headers["chatgpt-account-id"]).toBeUndefined()
  680. }),
  681. )
  682. it.effect("loads dynamic native provider packages through the injected package loader", () =>
  683. Effect.gen(function* () {
  684. const native = yield* ModelResolver.fromCatalogModel(
  685. model(Provider.aisdk("@ai-sdk/openai"), {
  686. settings: { baseURL: "https://openai.example/v1" },
  687. }),
  688. )
  689. const resolved = yield* ModelResolver.fromCatalogModel(
  690. model("@opencode-ai/ai/providers/custom", {
  691. settings: { region: "test" },
  692. headers: { "x-package": "header" },
  693. body: { custom: true },
  694. }),
  695. undefined,
  696. {
  697. loadPackage: (specifier) => {
  698. expect(specifier).toBe("@opencode-ai/ai/providers/custom")
  699. return Effect.succeed({
  700. model: (modelID, settings) => {
  701. expect(modelID).toBe("api-test-model")
  702. expect(settings).toEqual({
  703. region: "test",
  704. headers: { "x-package": "header" },
  705. body: { custom: true },
  706. limits: { context: 100, output: 20 },
  707. })
  708. return LanguageModel.make({ id: modelID, provider: "package-provider", route: native.route })
  709. },
  710. })
  711. },
  712. },
  713. )
  714. expect(resolved).toMatchObject({ id: "api-test-model", provider: "test-provider" })
  715. }),
  716. )
  717. it.effect("maps OAuth credentials to native provider auth settings", () =>
  718. Effect.gen(function* () {
  719. const native = yield* ModelResolver.fromCatalogModel(
  720. model(Provider.aisdk("@ai-sdk/openai"), {
  721. settings: { baseURL: "https://openai.example/v1" },
  722. }),
  723. )
  724. const credential = Credential.OAuth.make({
  725. type: "oauth",
  726. methodID: Integration.MethodID.make("device"),
  727. access: "oauth-token",
  728. refresh: "refresh",
  729. expires: Date.now() + 60_000,
  730. })
  731. const packages = [
  732. ["@opencode-ai/ai/providers/google-vertex", "accessToken"],
  733. ["@opencode-ai/ai/providers/google-vertex/gemini", "accessToken"],
  734. ["@opencode-ai/ai/providers/google-vertex/chat", "accessToken"],
  735. ["@opencode-ai/ai/providers/google-vertex/responses", "accessToken"],
  736. ["@opencode-ai/ai/providers/google-vertex/messages", "accessToken"],
  737. ["@opencode-ai/ai/providers/anthropic", "authToken"],
  738. ["@opencode-ai/ai/providers/anthropic-compatible", "authToken"],
  739. ] as const
  740. yield* Effect.forEach(packages, ([specifier, key]) =>
  741. ModelResolver.fromCatalogModel(model(specifier, { settings: { apiKey: "configured-key" } }), credential, {
  742. loadPackage: () =>
  743. Effect.succeed({
  744. model: (modelID, settings) => {
  745. expect(settings).toMatchObject({ [key]: "oauth-token" })
  746. expect(settings).not.toHaveProperty("apiKey")
  747. return LanguageModel.make({ id: modelID, provider: "package-provider", route: native.route })
  748. },
  749. }),
  750. }),
  751. )
  752. }),
  753. )
  754. it.effect("routes supported AISDK catalog packages through native provider packages", () =>
  755. Effect.gen(function* () {
  756. const native = yield* ModelResolver.fromCatalogModel(model(Provider.aisdk("@ai-sdk/openai")))
  757. const packages = [
  758. [
  759. "@ai-sdk/google",
  760. "@opencode-ai/ai/providers/google",
  761. { thinkingConfig: { thinkingLevel: "high" } },
  762. { gemini: { thinkingConfig: { thinkingLevel: "high" } } },
  763. ],
  764. [
  765. "@openrouter/ai-sdk-provider",
  766. "@opencode-ai/ai/providers/openrouter",
  767. { reasoning: { effort: "high" } },
  768. { openrouter: { reasoning: { effort: "high" } } },
  769. ],
  770. [
  771. "@ai-sdk/xai",
  772. "@opencode-ai/ai/providers/xai",
  773. { reasoningEffort: "high" },
  774. { xai: { reasoningEffort: "high" } },
  775. ],
  776. ] as const
  777. yield* Effect.forEach(packages, ([catalogPackage, nativePackage, sourceOptions, providerOptions]) =>
  778. ModelResolver.fromCatalogModel(
  779. model(Provider.aisdk(catalogPackage), {
  780. modelID: "api-model",
  781. settings: { baseURL: "https://provider.example/v1", ...sourceOptions },
  782. headers: { "x-provider": "header" },
  783. body: { custom: true },
  784. }),
  785. Credential.Key.make({ type: "key", key: "secret" }),
  786. {
  787. loadPackage: (specifier) => {
  788. expect(specifier).toBe(nativePackage)
  789. return Effect.succeed({
  790. model: (modelID, settings) => {
  791. expect(modelID).toBe("api-model")
  792. expect(settings).toMatchObject({
  793. apiKey: "secret",
  794. baseURL: "https://provider.example/v1",
  795. headers: { "x-provider": "header" },
  796. body: { custom: true },
  797. limits: { context: 100, output: 20 },
  798. providerOptions,
  799. })
  800. return LanguageModel.make({ id: modelID, provider: "native-provider", route: native.route })
  801. },
  802. })
  803. },
  804. loadAISDK: () => Effect.die("AI SDK loader should not be called"),
  805. },
  806. ),
  807. )
  808. }),
  809. )
  810. it.effect("routes Vertex Anthropic catalog models through native Messages", () =>
  811. Effect.gen(function* () {
  812. const native = yield* ModelResolver.fromCatalogModel(model(Provider.aisdk("@ai-sdk/openai")))
  813. const credential = Credential.OAuth.make({
  814. type: "oauth",
  815. methodID: Integration.MethodID.make("device"),
  816. access: "vertex-token",
  817. refresh: "refresh",
  818. expires: Date.now() + 60_000,
  819. })
  820. const resolved = yield* ModelResolver.fromCatalogModel(
  821. model(Provider.aisdk("@ai-sdk/google-vertex/anthropic"), {
  822. modelID: "claude-sonnet-4-6",
  823. settings: {
  824. location: "eu",
  825. project: "vertex-project",
  826. thinking: { type: "adaptive", display: "summarized" },
  827. effort: "high",
  828. },
  829. }),
  830. credential,
  831. {
  832. loadPackage: (specifier) => {
  833. expect(specifier).toBe("@opencode-ai/ai/providers/google-vertex/messages")
  834. return Effect.succeed({
  835. model: (modelID, settings) => {
  836. expect(modelID).toBe("claude-sonnet-4-6")
  837. expect(settings).toMatchObject({
  838. accessToken: "vertex-token",
  839. location: "eu",
  840. project: "vertex-project",
  841. providerOptions: {
  842. anthropic: {
  843. thinking: { type: "adaptive", display: "summarized" },
  844. effort: "high",
  845. },
  846. },
  847. })
  848. return LanguageModel.make({ id: modelID, provider: "native-provider", route: native.route })
  849. },
  850. })
  851. },
  852. loadAISDK: () => Effect.die("AI SDK loader should not be called"),
  853. },
  854. )
  855. expect(resolved).toMatchObject({ id: "claude-sonnet-4-6", provider: "test-provider" })
  856. }),
  857. )
  858. it.effect("merges mapped OpenRouter headers and body with catalog overlays", () =>
  859. ModelResolver.fromCatalogModel(
  860. model(Provider.aisdk("@openrouter/ai-sdk-provider"), {
  861. settings: {
  862. appName: "OpenCode",
  863. appUrl: "https://opencode.ai",
  864. extraBody: { transforms: ["middle-out"], provider: { sort: "price" } },
  865. },
  866. headers: { "X-OpenRouter-Title": "Custom" },
  867. body: { provider: { only: ["anthropic"] } },
  868. }),
  869. undefined,
  870. {
  871. loadPackage: () =>
  872. Effect.succeed({
  873. model: (modelID, settings) => {
  874. expect(settings.headers).toEqual({
  875. "HTTP-Referer": "https://opencode.ai",
  876. "X-OpenRouter-Title": "Custom",
  877. })
  878. expect(settings.body).toEqual({
  879. transforms: ["middle-out"],
  880. provider: { sort: "price", only: ["anthropic"] },
  881. })
  882. return LanguageModel.make({ id: modelID, provider: "openrouter", route: OpenAIChat.route })
  883. },
  884. }),
  885. },
  886. ),
  887. )
  888. it.effect("loads supported AISDK catalog packages as native routes", () =>
  889. Effect.gen(function* () {
  890. const google = yield* ModelResolver.fromCatalogModel(
  891. model(Provider.aisdk("@ai-sdk/google"), { settings: { thinkingConfig: { thinkingBudget: 1_024 } } }),
  892. )
  893. const openrouter = yield* ModelResolver.fromCatalogModel(
  894. model(Provider.aisdk("@openrouter/ai-sdk-provider"), {
  895. settings: { reasoning: { effort: "high" } },
  896. }),
  897. )
  898. const xai = yield* ModelResolver.fromCatalogModel(
  899. model(Provider.aisdk("@ai-sdk/xai"), { settings: { reasoningEffort: "high" } }),
  900. )
  901. const bedrock = yield* ModelResolver.fromCatalogModel(
  902. model(Provider.aisdk("@ai-sdk/amazon-bedrock"), {
  903. settings: { region: "us-east-1", topP: 0.8, serviceTier: "priority" },
  904. body: {},
  905. }),
  906. )
  907. const mantle = yield* ModelResolver.fromCatalogModel(
  908. model(Provider.aisdk("@ai-sdk/amazon-bedrock/mantle"), {
  909. modelID: "openai.gpt-oss-120b",
  910. settings: { region: "us-east-1" },
  911. }),
  912. )
  913. expect(google.route.id).toBe("gemini")
  914. expect(google.route.defaults.providerOptions).toEqual({
  915. gemini: { thinkingConfig: { thinkingBudget: 1_024 } },
  916. })
  917. expect(openrouter.route.id).toBe("openrouter")
  918. expect(openrouter.route.defaults.providerOptions).toEqual({ openrouter: { reasoning: { effort: "high" } } })
  919. expect(xai.route.id).toBe("openai-responses")
  920. expect(xai.route.defaults.providerOptions).toEqual({
  921. xai: { reasoningEffort: "high", store: false },
  922. })
  923. expect(bedrock.route.id).toBe("bedrock-converse")
  924. expect(bedrock.route.defaults.generation).toEqual({ topP: 0.8 })
  925. expect(bedrock.route.defaults.http?.body).toEqual({ serviceTier: { type: "priority" } })
  926. expect(mantle.route.id).toBe("bedrock-mantle-responses")
  927. }),
  928. )
  929. it.effect("loads arbitrary AISDK packages through the injected AISDK loader", () =>
  930. Effect.gen(function* () {
  931. const native = yield* ModelResolver.fromCatalogModel(
  932. model(Provider.aisdk("@ai-sdk/openai"), {
  933. settings: { baseURL: "https://openai.example/v1" },
  934. }),
  935. )
  936. const resolved = yield* ModelResolver.fromCatalogModel(
  937. model(Provider.aisdk("@ai-sdk/mistral"), {
  938. modelID: "mistral-api-model",
  939. settings: { project: "test" },
  940. headers: { "x-aisdk": "header" },
  941. body: { custom: true },
  942. }),
  943. Credential.Key.make({
  944. type: "key",
  945. key: "fallback-secret",
  946. configuration: { accountId: "account" },
  947. }),
  948. {
  949. loadAISDK: (runtime) =>
  950. Effect.sync(() => {
  951. expect(runtime).toMatchObject({
  952. id: "test-model",
  953. modelID: "mistral-api-model",
  954. providerID: "test-provider",
  955. package: Provider.aisdk("@ai-sdk/mistral"),
  956. settings: { project: "test", apiKey: "fallback-secret", accountId: "account" },
  957. headers: { "x-aisdk": "header" },
  958. body: { custom: true },
  959. })
  960. return LanguageModel.make({
  961. id: runtime.modelID ?? runtime.id,
  962. provider: runtime.providerID,
  963. route: native.route,
  964. })
  965. }),
  966. },
  967. )
  968. expect(resolved).toMatchObject({ id: "mistral-api-model", provider: "test-provider" })
  969. }),
  970. )
  971. it.effect("rejects unresolved variables before loading opaque AISDK packages", () =>
  972. withEnv({ REQUIRED_HOST: undefined }, () =>
  973. Effect.gen(function* () {
  974. const failure = yield* ModelResolver.fromCatalogModel(
  975. model(Provider.aisdk("@ai-sdk/mistral"), {
  976. settings: { baseURL: "https://${REQUIRED_HOST}/v1" },
  977. }),
  978. undefined,
  979. { loadAISDK: () => Effect.die("AI SDK loader should not be called") },
  980. ).pipe(Effect.flip)
  981. expect(failure).toMatchObject({
  982. _tag: "SessionRunnerModel.UnresolvedProviderVariablesError",
  983. variables: ["REQUIRED_HOST"],
  984. })
  985. }),
  986. ),
  987. )
  988. it.effect("rejects placeholders introduced by environment expansion before loading providers", () =>
  989. withEnv({ PROVIDER_HOST: "${MISSING_HOST}", MISSING_HOST: undefined }, () =>
  990. Effect.gen(function* () {
  991. const failure = yield* ModelResolver.fromCatalogModel(
  992. model(Provider.aisdk("@ai-sdk/mistral"), {
  993. settings: { baseURL: "https://${PROVIDER_HOST}/v1" },
  994. }),
  995. undefined,
  996. { loadAISDK: () => Effect.die("AI SDK loader should not be called") },
  997. ).pipe(Effect.flip)
  998. expect(failure).toMatchObject({
  999. _tag: "SessionRunnerModel.UnresolvedProviderVariablesError",
  1000. variables: ["MISSING_HOST"],
  1001. })
  1002. }),
  1003. ),
  1004. )
  1005. it.effect("rejects unresolved variables before loading native provider packages", () =>
  1006. withEnv({ REQUIRED_HOST: undefined }, () =>
  1007. Effect.gen(function* () {
  1008. const failure = yield* ModelResolver.fromCatalogModel(
  1009. model(Provider.aisdk("@ai-sdk/google"), {
  1010. settings: { baseURL: "https://${REQUIRED_HOST}/v1" },
  1011. }),
  1012. undefined,
  1013. { loadPackage: () => Effect.die("Native package loader should not be called") },
  1014. ).pipe(Effect.flip)
  1015. expect(failure).toMatchObject({
  1016. _tag: "SessionRunnerModel.UnresolvedProviderVariablesError",
  1017. variables: ["REQUIRED_HOST"],
  1018. })
  1019. }),
  1020. ),
  1021. )
  1022. it.effect("rejects AISDK packages without an available loader", () =>
  1023. Effect.gen(function* () {
  1024. const failure = yield* ModelResolver.fromCatalogModel(
  1025. model(Provider.aisdk("@ai-sdk/mistral"), {
  1026. settings: { baseURL: "https://mistral.example/v1" },
  1027. }),
  1028. ).pipe(Effect.flip)
  1029. expect(failure).toMatchObject({
  1030. _tag: "SessionRunnerModel.UnsupportedPackageError",
  1031. providerID: "test-provider",
  1032. modelID: "test-model",
  1033. package: "aisdk:@ai-sdk/mistral",
  1034. })
  1035. expect(failure.message).toBe("Unsupported package for test-provider/test-model: aisdk:@ai-sdk/mistral")
  1036. }),
  1037. )
  1038. it.effect("drops an empty API key before loading an AISDK package", () =>
  1039. Effect.gen(function* () {
  1040. const native = yield* ModelResolver.fromCatalogModel(
  1041. model(Provider.aisdk("@ai-sdk/openai"), {
  1042. settings: { baseURL: "https://openai.example/v1" },
  1043. }),
  1044. )
  1045. yield* ModelResolver.fromCatalogModel(
  1046. model(Provider.aisdk("@ai-sdk/mistral"), {
  1047. settings: { apiKey: "", baseURL: "https://mistral.example/v1" },
  1048. }),
  1049. undefined,
  1050. {
  1051. loadAISDK: (runtime) =>
  1052. Effect.sync(() => {
  1053. expect(runtime.settings).not.toHaveProperty("apiKey")
  1054. return native
  1055. }),
  1056. },
  1057. )
  1058. }),
  1059. )
  1060. it.effect("reports whether a catalog model declares a provider package", () =>
  1061. Effect.sync(() => {
  1062. expect(ModelResolver.supported(model(Provider.aisdk("@ai-sdk/openai")))).toBe(true)
  1063. expect(ModelResolver.supported(model("@opencode-ai/ai/providers/custom"))).toBe(true)
  1064. expect(ModelResolver.supported(model(undefined))).toBe(false)
  1065. }),
  1066. )
  1067. })