model-resolver.test.ts 32 KB

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